Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1ea9c5c
Merge pull request #159 from netboxlabs/feature
bctiemann Aug 18, 2025
79ad9c6
161 fix multi object field
arthanson Aug 19, 2025
a841e1d
161 fix multi object field
arthanson Aug 19, 2025
b5d2d03
Merge pull request #162 from netboxlabs/161-multi-object-3
bctiemann Aug 19, 2025
0f477a4
Check if unique flag raise validation error if non unique data
arthanson Aug 19, 2025
c111f86
NPL-411 raise validation error if using reserved names for fields
arthanson Aug 19, 2025
962de13
Merge pull request #163 from netboxlabs/npl-389-unique
bctiemann Aug 20, 2025
8abaefa
Merge pull request #164 from netboxlabs/NPL-411-reserved-names
bctiemann Aug 20, 2025
1f0197b
Revert "Check if unique flag raise validation error if non unique data"
bctiemann Aug 20, 2025
1bc72cd
Update docs for 0.2.0
mrmrcoleman Aug 20, 2025
1178246
Merge pull request #167 from netboxlabs/revert-163-npl-389-unique
bctiemann Aug 20, 2025
91335ba
NPL-389 fix unique check
arthanson Aug 20, 2025
636d67d
Merge pull request #168 from netboxlabs/0.2.0-docs
mrmrcoleman Aug 20, 2025
a850a9c
Merge pull request #170 from netboxlabs/npl-389-unique-3
bctiemann Aug 20, 2025
b311f7b
Bump version to 0.2.0 in pyproject.toml
bctiemann Aug 20, 2025
34fb653
174 fix delete of CO on detail page
arthanson Aug 20, 2025
3216a93
174 fix delete of CO on detail page
arthanson Aug 20, 2025
1cf0c80
Merge pull request #175 from netboxlabs/174-delete
bctiemann Aug 20, 2025
f2e2da1
Merge pull request #173 from netboxlabs/bump-pyproject-version
bctiemann Aug 20, 2025
0ee2b1e
171 fix ObjectChange for update not showing on restart
arthanson Aug 22, 2025
7d0b6c6
171 fix ObjectChange for update not showing on restart
arthanson Aug 22, 2025
6ab6e51
171 fix ObjectChange for update not showing on restart
arthanson Aug 22, 2025
2bbf7ab
183 fix ruff errors
arthanson Aug 22, 2025
87ce4de
merge feature
arthanson Aug 29, 2025
8fb10d5
fix system check m2m errors
arthanson Sep 2, 2025
fcd8621
fix ruff errors
arthanson Sep 2, 2025
b5db66b
support branching
arthanson Sep 2, 2025
d378150
fix related name for ObjectFieldType
arthanson Sep 2, 2025
5ec0bc5
fix ruff check
arthanson Sep 2, 2025
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
15 changes: 10 additions & 5 deletions netbox_custom_objects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from django.db.utils import DatabaseError, OperationalError, ProgrammingError
from netbox.plugins import PluginConfig

from .constants import APP_LABEL as APP_LABEL


def is_running_migration():
"""
Expand Down Expand Up @@ -106,11 +108,14 @@ def get_models(self, include_auto_created=False, include_swapped=False):

custom_object_types = CustomObjectType.objects.all()
for custom_type in custom_object_types:
# Only yield already cached models during discovery
if CustomObjectType.is_model_cached(custom_type.id):
model = CustomObjectType.get_cached_model(custom_type.id)
if model:
yield model
model = custom_type.get_model()
if model:
yield model

# If include_auto_created is True, also yield through models
if include_auto_created and hasattr(model, '_through_models'):
for through_model in model._through_models:
yield through_model


config = CustomObjectsPluginConfig
18 changes: 16 additions & 2 deletions netbox_custom_objects/field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from utilities.templatetags.builtins.filters import linkify, render_markdown

from netbox_custom_objects.constants import APP_LABEL
from netbox_custom_objects.utilities import generate_model


class LazyForeignKey(ForeignKey):
Expand Down Expand Up @@ -405,11 +406,15 @@ def get_model_field(self, field, **kwargs):
if custom_object_type.id == field.custom_object_type.id:
# For self-referential fields, use LazyForeignKey to defer resolution
model_name = f"{APP_LABEL}.{custom_object_type.get_table_model_name(custom_object_type.id)}"
# Generate a unique related_name to prevent reverse accessor conflicts
table_model_name = field.custom_object_type.get_table_model_name(field.custom_object_type.id).lower()
related_name = f"{table_model_name}_{field.name}_set"
f = LazyForeignKey(
model_name,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name=related_name,
**field_kwargs
)
return f
Expand All @@ -420,11 +425,17 @@ def get_model_field(self, field, **kwargs):
# We're in a circular reference, don't call get_model() to prevent recursion
# Use a string reference instead
model_name = f"{APP_LABEL}.{custom_object_type.get_table_model_name(custom_object_type.id)}"
# Generate a unique related_name to prevent reverse accessor conflicts
table_model_name = field.custom_object_type.get_table_model_name(
field.custom_object_type.id
).lower()
related_name = f"{table_model_name}_{field.name}_set"
f = models.ForeignKey(
model_name,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name=related_name,
**field_kwargs
)
return f
Expand All @@ -435,8 +446,11 @@ def get_model_field(self, field, **kwargs):
to_ct = f"{content_type.app_label}.{to_model}"
model = apps.get_model(to_ct)

# Generate a unique related_name to prevent reverse accessor conflicts
table_model_name = field.custom_object_type.get_table_model_name(field.custom_object_type.id).lower()
related_name = f"{table_model_name}_{field.name}_set"
f = models.ForeignKey(
model, null=True, blank=True, on_delete=models.CASCADE, **field_kwargs
model, null=True, blank=True, on_delete=models.CASCADE, related_name=related_name, **field_kwargs
)

return f
Expand Down Expand Up @@ -719,7 +733,7 @@ def get_through_model(self, field, model_string):
),
}

return type(field.through_model_name, (models.Model,), attrs)
return generate_model(field.through_model_name, (models.Model,), attrs)

def get_model_field(self, field, **kwargs):
"""
Expand Down
50 changes: 31 additions & 19 deletions netbox_custom_objects/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import decimal
import re
import warnings
from datetime import date, datetime

import django_filters
Expand Down Expand Up @@ -51,6 +50,7 @@

from netbox_custom_objects.constants import APP_LABEL, RESERVED_FIELD_NAMES
from netbox_custom_objects.field_types import FIELD_TYPE_CLASS
from netbox_custom_objects.utilities import generate_model


class UniquenessConstraintTestError(Exception):
Expand Down Expand Up @@ -422,6 +422,9 @@ def _after_model_generation(self, attrs, model):
# Get the set of fields that were skipped due to recursion
skipped_fields = attrs.get("_skipped_fields", set())

# Collect through models during after_model_generation
through_models = []

for field_object in all_field_objects.values():
field_name = field_object["name"]

Expand All @@ -433,15 +436,28 @@ def _after_model_generation(self, attrs, model):
# Fields might be skipped due to recursion prevention
if hasattr(model._meta, 'get_field'):
try:
model._meta.get_field(field_name)
field = model._meta.get_field(field_name)
# Field exists, process it
field_object["type"].after_model_generation(
field_object["field"], model, field_name
)

# Collect through models from M2M fields
if hasattr(field, 'remote_field') and hasattr(field.remote_field, 'through'):
through_model = field.remote_field.through
# Only collect custom through models, not auto-created Django ones
if (through_model and through_model not in through_models and
hasattr(through_model._meta, 'app_label') and
through_model._meta.app_label == APP_LABEL):
through_models.append(through_model)

except Exception:
# Field doesn't exist (likely skipped due to recursion), skip processing
continue

# Store through models on the model for yielding in get_models()
model._through_models = through_models

def get_collision_safe_order_id_idx_name(self):
return f"tbl_order_id_{self.id}_idx"

Expand Down Expand Up @@ -602,22 +618,14 @@ def wrapped_post_through_setup(self, cls):

TM.post_through_setup = wrapped_post_through_setup

# Suppress RuntimeWarning about model already being registered
# TODO: Remove this once we have a better way to handle model registration
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", category=RuntimeWarning, message=".*was already registered.*"
try:
model = generate_model(
str(model_name),
(CustomObject, models.Model),
attrs,
)

try:
model = type(
str(model_name),
(CustomObject, models.Model),
attrs,
)
finally:
# Restore the original method
TM.post_through_setup = original_post_through_setup
finally:
TM.post_through_setup = original_post_through_setup

# Register the main model with Django's app registry
try:
Expand All @@ -635,6 +643,9 @@ def wrapped_post_through_setup(self, cls):
# Cache the generated model
if not no_cache:
self._model_cache[self.id] = model
# Do the clear cache now that we have it in the cache so there
# is no recursion.
apps.clear_cache()

# Register the serializer for this model
if not manytomany_models:
Expand Down Expand Up @@ -664,6 +675,7 @@ def create_model(self):
# Ensure the ContentType exists and is immediately available
ct = self.get_or_create_content_type()
features = get_model_features(model)
ct.features = features + ['branching']
ct.public = True
ct.features = features
ct.save()
Expand Down Expand Up @@ -1424,7 +1436,7 @@ def save(self, *args, **kwargs):
"managed": True,
},
)
old_through_model = type(
old_through_model = generate_model(
f"TempOld{self.original.through_model_name}",
(models.Model,),
{
Expand Down Expand Up @@ -1455,7 +1467,7 @@ def save(self, *args, **kwargs):
"managed": True,
},
)
new_through_model = type(
new_through_model = generate_model(
f"TempNew{self.through_model_name}",
(models.Model,),
{
Expand Down
25 changes: 25 additions & 0 deletions netbox_custom_objects/utilities.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import warnings

from django.apps import apps

from netbox_custom_objects.constants import APP_LABEL

__all__ = (
"AppsProxy",
"generate_model",
"get_viewname",
)

Expand Down Expand Up @@ -83,3 +86,25 @@ def get_viewname(model, action=None, rest_api=False):
viewname = f"{viewname}_{action}"

return viewname


def generate_model(*args, **kwargs):
"""
Create a model.
"""
# Monkey patch apps.clear_cache to do nothing
apps.clear_cache = lambda: None

# Suppress RuntimeWarning about model already being registered
# TODO: Remove this once we have a better way to handle model registration
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", category=RuntimeWarning, message=".*was already registered.*"
)

try:
model = type(*args, **kwargs)
finally:
apps.clear_cache = apps.clear_cache

return model