Skip to content

Commit 1ea9c5c

Browse files
authored
Merge pull request #159 from netboxlabs/feature
Merge Feature
2 parents cc6802b + c65d985 commit 1ea9c5c

29 files changed

+3976
-574
lines changed

.github/workflows/lint-tests.yaml

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
name: Lint and tests
2+
on:
3+
workflow_dispatch:
4+
pull_request:
5+
push:
6+
7+
concurrency:
8+
group: ${{ github.workflow }}
9+
cancel-in-progress: false
10+
11+
permissions:
12+
contents: write
13+
checks: write
14+
pull-requests: write
15+
16+
jobs:
17+
lint:
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 10
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v4
23+
- name: Setup Python
24+
uses: actions/setup-python@v5
25+
with:
26+
python-version: "3.10"
27+
- name: Install dependencies
28+
run: |
29+
python -m pip install --upgrade pip
30+
pip install .
31+
pip install .[dev]
32+
pip install .[test]
33+
- name: Run ruff
34+
run: ruff check
35+
tests:
36+
runs-on: ubuntu-latest
37+
timeout-minutes: 10
38+
strategy:
39+
matrix:
40+
python-version: [ "3.10", "3.11", "3.12" ]
41+
services:
42+
redis:
43+
image: redis
44+
ports:
45+
- 6379:6379
46+
postgres:
47+
image: postgres
48+
env:
49+
POSTGRES_USER: netbox
50+
POSTGRES_PASSWORD: netbox
51+
options: >-
52+
--health-cmd pg_isready
53+
--health-interval 10s
54+
--health-timeout 5s
55+
--health-retries 5
56+
ports:
57+
- 5432:5432
58+
steps:
59+
- name: Checkout netbox-custom-objects
60+
uses: actions/checkout@v4
61+
with:
62+
path: netbox-custom-objects
63+
- name: Setup Python ${{ matrix.python-version }}
64+
uses: actions/setup-python@v5
65+
with:
66+
python-version: ${{ matrix.python-version }}
67+
- name: Checkout netbox
68+
uses: actions/checkout@v4
69+
with:
70+
repository: "netbox-community/netbox"
71+
path: netbox
72+
ref: feature
73+
- name: Install netbox-custom-objects
74+
working-directory: netbox-custom-objects
75+
run: |
76+
# Include tests directory for test
77+
sed -i 's/exclude-package-data/#exclude-package-data/g' pyproject.toml
78+
python -m pip install --upgrade pip
79+
pip install .
80+
pip install .[test]
81+
- name: Install dependencies & configure plugin
82+
working-directory: netbox
83+
run: |
84+
ln -s $(pwd)/../netbox-custom-objects/testing/configuration.py netbox/netbox/configuration.py
85+
86+
python -m pip install --upgrade pip
87+
pip install -r requirements.txt -U
88+
- name: Run tests
89+
working-directory: netbox
90+
run: |
91+
python netbox/manage.py test netbox_custom_objects.tests --keepdb

netbox_custom_objects/__init__.py

Lines changed: 143 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,159 @@
1+
import sys
2+
import warnings
3+
4+
from django.core.exceptions import AppRegistryNotReady
5+
from django.db import transaction
6+
from django.db.utils import DatabaseError, OperationalError, ProgrammingError
17
from netbox.plugins import PluginConfig
28

39

10+
def is_running_migration():
11+
"""
12+
Check if the code is currently running during a Django migration.
13+
"""
14+
# Check if 'makemigrations' or 'migrate' command is in sys.argv
15+
if any(cmd in sys.argv for cmd in ["makemigrations", "migrate"]):
16+
return True
17+
18+
return False
19+
20+
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+
51+
def check_custom_object_type_table_exists():
52+
"""
53+
Check if the CustomObjectType table exists in the database.
54+
Returns True if the table exists, False otherwise.
55+
"""
56+
from .models import CustomObjectType
57+
58+
try:
59+
# Try to query the model - if the table doesn't exist, this will raise an exception
60+
# this check and the transaction.atomic() is only required when running tests as the
61+
# migration check doesn't work correctly in the test environment
62+
with transaction.atomic():
63+
# Force immediate execution by using first()
64+
CustomObjectType.objects.first()
65+
return True
66+
except (OperationalError, ProgrammingError, DatabaseError):
67+
# Catch database-specific errors (table doesn't exist, permission issues, etc.)
68+
return False
69+
70+
471
# Plugin Configuration
572
class CustomObjectsPluginConfig(PluginConfig):
673
name = "netbox_custom_objects"
774
verbose_name = "Custom Objects"
875
description = "A plugin to manage custom objects in NetBox"
9-
version = "0.1.0"
76+
version = "0.2.0"
1077
base_url = "custom-objects"
11-
min_version = "4.2.0"
78+
min_version = "4.4.0"
1279
default_settings = {}
1380
required_settings = []
1481
template_extensions = "template_content.template_extensions"
82+
_in_get_models = False # Recursion guard
83+
84+
def get_model(self, model_name, require_ready=True):
85+
try:
86+
# if the model is already loaded, return it
87+
return super().get_model(model_name, require_ready)
88+
except LookupError:
89+
try:
90+
self.apps.check_apps_ready()
91+
except AppRegistryNotReady:
92+
raise
93+
94+
# only do database calls if we are sure the app is ready to avoid
95+
# Django warnings
96+
if "table" not in model_name.lower() or "model" not in model_name.lower():
97+
raise LookupError(
98+
"App '%s' doesn't have a '%s' model." % (self.label, model_name)
99+
)
100+
101+
from .models import CustomObjectType
102+
103+
custom_object_type_id = int(
104+
model_name.replace("table", "").replace("model", "")
105+
)
106+
107+
try:
108+
obj = CustomObjectType.objects.get(pk=custom_object_type_id)
109+
except CustomObjectType.DoesNotExist:
110+
raise LookupError(
111+
"App '%s' doesn't have a '%s' model." % (self.label, model_name)
112+
)
113+
114+
return obj.get_model()
115+
116+
def get_models(self, include_auto_created=False, include_swapped=False):
117+
"""Return all models for this plugin, including custom object type models."""
118+
119+
# Get the regular Django models first
120+
for model in super().get_models(include_auto_created, include_swapped):
121+
yield model
122+
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
127+
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
15148

16-
# def get_model(self, model_name, require_ready=True):
17-
# if require_ready:
18-
# self.apps.check_models_ready()
19-
# else:
20-
# self.apps.check_apps_ready()
21-
#
22-
# if model_name.lower() in self.models:
23-
# return self.models[model_name.lower()]
24-
#
25-
# from .models import CustomObjectType
26-
# if "table" not in model_name.lower() or "model" not in model_name.lower():
27-
# raise LookupError(
28-
# "App '%s' doesn't have a '%s' model." % (self.label, model_name)
29-
# )
30-
#
31-
# custom_object_type_id = int(model_name.replace("table", "").replace("model", ""))
32-
#
33-
# try:
34-
# obj = CustomObjectType.objects.get(pk=custom_object_type_id)
35-
# except CustomObjectType.DoesNotExist:
36-
# raise LookupError(
37-
# "App '%s' doesn't have a '%s' model." % (self.label, model_name)
38-
# )
39-
# return obj.get_model()
149+
custom_object_types = CustomObjectType.objects.all()
150+
for custom_type in custom_object_types:
151+
model = custom_type.get_model()
152+
if model:
153+
yield model
154+
finally:
155+
# Clean up the recursion guard
156+
self._in_get_models = False
40157

41158

42159
config = CustomObjectsPluginConfig

netbox_custom_objects/api/serializers.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import logging
2+
import sys
3+
4+
from core.models import ObjectType
15
from django.contrib.contenttypes.models import ContentType
26
from extras.choices import CustomFieldTypeChoices
37
from netbox.api.serializers import NetBoxModelSerializer
@@ -6,7 +10,11 @@
610
from rest_framework.reverse import reverse
711

812
from netbox_custom_objects import field_types
9-
from netbox_custom_objects.models import CustomObject, CustomObjectType, CustomObjectTypeField
13+
from netbox_custom_objects.models import (CustomObject, CustomObjectType,
14+
CustomObjectTypeField)
15+
16+
logger = logging.getLogger('netbox_custom_objects.api.serializers')
17+
1018

1119
__all__ = (
1220
"CustomObjectTypeSerializer",
@@ -59,10 +67,10 @@ def validate(self, attrs):
5967
CustomFieldTypeChoices.TYPE_MULTIOBJECT,
6068
]:
6169
try:
62-
attrs["related_object_type"] = ContentType.objects.get(
70+
attrs["related_object_type"] = ObjectType.objects.get(
6371
app_label=app_label, model=model
6472
)
65-
except ContentType.DoesNotExist:
73+
except ObjectType.DoesNotExist:
6674
raise ValidationError(
6775
"Must provide valid app_label and model for object field type."
6876
)
@@ -197,17 +205,24 @@ def get_field_data(self, obj):
197205

198206
def get_serializer_class(model):
199207
model_fields = model.custom_object_type.fields.all()
208+
209+
# Create field list including all necessary fields
210+
base_fields = ["id", "url", "display", "created", "last_updated", "tags"]
211+
custom_field_names = [field.name for field in model_fields]
212+
all_fields = base_fields + custom_field_names
213+
200214
meta = type(
201215
"Meta",
202216
(),
203217
{
204218
"model": model,
205-
"fields": "__all__",
219+
"fields": all_fields,
220+
"brief_fields": ("id", "url", "display"),
206221
},
207222
)
208223

209224
def get_url(self, obj):
210-
# Unsaved objects will not yet have a valid URL.
225+
"""Generate the API URL for this object"""
211226
if hasattr(obj, "pk") and obj.pk in (None, ""):
212227
return None
213228

@@ -221,24 +236,38 @@ def get_url(self, obj):
221236
format = self.context.get("format")
222237
return reverse(view_name, kwargs=kwargs, request=request, format=format)
223238

239+
def get_display(self, obj):
240+
"""Get display representation of the object"""
241+
return str(obj)
242+
243+
# Create basic attributes for the serializer
224244
attrs = {
225245
"Meta": meta,
226-
"__module__": "database.serializers",
246+
"__module__": "netbox_custom_objects.api.serializers",
227247
"url": serializers.SerializerMethodField(),
228248
"get_url": get_url,
249+
"display": serializers.SerializerMethodField(),
250+
"get_display": get_display,
229251
}
230252

231253
for field in model_fields:
232254
field_type = field_types.FIELD_TYPE_CLASS[field.type]()
233255
try:
234256
attrs[field.name] = field_type.get_serializer_field(field)
235257
except NotImplementedError:
236-
print(f"serializer: {field.name} field is not implemented; using a default serializer field")
258+
logger.debug(
259+
"serializer: {} field is not implemented; using a default serializer field".format(field.name)
260+
)
237261

262+
serializer_name = f"{model._meta.object_name}Serializer"
238263
serializer = type(
239-
f"{model._meta.object_name}Serializer",
240-
(serializers.ModelSerializer,),
264+
serializer_name,
265+
(NetBoxModelSerializer,),
241266
attrs,
242267
)
243268

269+
# Register the serializer in the current module so NetBox can find it
270+
current_module = sys.modules[__name__]
271+
setattr(current_module, serializer_name, serializer)
272+
244273
return serializer

0 commit comments

Comments
 (0)