Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
b97ec29
CustomObjectType schema exporter
bctiemann Apr 7, 2026
53e41f1
Ruff fixes
bctiemann Apr 7, 2026
363826c
Fix MRO and add validation/notes
bctiemann Apr 7, 2026
042b493
Merge branch 'feature' into 388-schema-exporter
bctiemann Apr 7, 2026
b1c25f5
Schema comparator tool
bctiemann Apr 7, 2026
33cba18
Upgrade executor tool
bctiemann Apr 8, 2026
ef12f1b
Ruff fixes
bctiemann Apr 8, 2026
a0f3a2a
Schema validation/apply endpoints
bctiemann Apr 8, 2026
3f21c6e
Validate semver fields
bctiemann Apr 8, 2026
6e941e1
Ruff fixes
bctiemann Apr 8, 2026
6023e30
Merge branch '386-schema-export' into 387-schema-comparator
bctiemann Apr 8, 2026
326c143
Remove 0006 migration
bctiemann Apr 8, 2026
7a04010
Cleanup
bctiemann Apr 9, 2026
4197403
Ruff fix
bctiemann Apr 9, 2026
fa581d0
Address review feedback
bctiemann Apr 9, 2026
13e3f40
Address review feedback
bctiemann Apr 9, 2026
46650d9
Merge branch '387-schema-comparator' into 389-upgrade-executor
bctiemann Apr 9, 2026
41d7b1a
Address review feedback and move all schema-related items into "schem…
bctiemann Apr 9, 2026
ee52d4c
Ruff fix
bctiemann Apr 9, 2026
2964ce3
Add tests and clarify docstrings
bctiemann Apr 9, 2026
69f5392
Add tests and clarify docstrings
bctiemann Apr 9, 2026
3919d9f
Merge branch '386-schema-export' into 390-schema-api-endpoints
bctiemann Apr 9, 2026
632dfc1
Merge branch '389-upgrade-executor' into 390-schema-api-endpoints
bctiemann Apr 9, 2026
be34886
Cleanup and fix imports
bctiemann Apr 9, 2026
eb0d0af
Add missing test file
bctiemann Apr 9, 2026
fc13bbf
Cleanup and add tests
bctiemann Apr 9, 2026
731c950
Merge branch '390-schema-api-endpoints' into 392-validate-semver-fields
bctiemann Apr 9, 2026
3a968bc
Fix migrations
bctiemann Apr 9, 2026
5c3906a
Cleanup and test coverage
bctiemann Apr 9, 2026
5b2a668
Test improvements/cleanup
bctiemann Apr 9, 2026
4944615
Merge feature into 392-validate-semver-fields
bctiemann Apr 29, 2026
9feee56
Fix ModuleNotFoundError: schema_format → schema.format in top-level c…
bctiemann Apr 29, 2026
0c9ada6
Fix infinite recursion in register_custom_object_search_index
bctiemann Apr 29, 2026
9617203
Fix infinite recursion in _after_model_generation via _meta.get_field()
bctiemann Apr 29, 2026
409efe6
Fix ContentType.DoesNotExist crash on startup before plugin ready()
bctiemann Apr 29, 2026
052a0b1
Defend serializer and model generation against null/missing related_o…
bctiemann Apr 30, 2026
f7f39f5
Convert ContentType.DoesNotExist to NotImplementedError in field type…
bctiemann Apr 30, 2026
fd4c006
Guard get_serializer_field against null related_object_type FK
bctiemann Apr 30, 2026
ed1418a
Catch NotImplementedError from _get_related_content_type in model gen…
bctiemann Apr 30, 2026
9cb440e
Downgrade invalid-field skip messages from WARNING to DEBUG
bctiemann Apr 30, 2026
a2b3191
Clean up piecemeal ContentType guard remnants
bctiemann Apr 30, 2026
bcf7f10
Add regression tests for null related_object_type and stub model sear…
bctiemann Apr 30, 2026
d48ca78
Move register_search_index regression test to CustomObjectTypeTestCase
bctiemann Apr 30, 2026
81863ea
Ruff fixes
bctiemann Apr 30, 2026
7ef7ae2
Fix three issues from code review
bctiemann Apr 30, 2026
af5f6ef
Fix CI: update stale exporter import path; remove redundant deferred …
bctiemann Apr 30, 2026
b1fd139
Fix SchemaApplyTestCase: grant add+change ObjectPermission to test user
bctiemann Apr 30, 2026
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
29 changes: 29 additions & 0 deletions netbox_custom_objects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@
_migrations_checked = None
_checking_migrations = False

# Set to True once ready() has completed and _model_cache is fully populated.
# get_models() checks this flag and skips dynamic model generation until it's True,
# preventing ContentType lookups from firing during other apps' ready() calls (e.g.
# dcim.ready() triggers Device._meta._relation_tree → apps.get_models()). After
# ready() sets this flag it calls apps.clear_cache(), so the next _relation_tree
# access recomputes with the full set of COT models.
_app_ready = False


def _migration_started(sender, **kwargs):
"""Signal handler for pre_migrate - sets the migration flag."""
Expand Down Expand Up @@ -209,6 +217,17 @@ def ready(self):
super().ready()
return

# Signal that ready() has fully completed. get_models() checks this flag
# before attempting dynamic model generation so that early calls triggered
# by other apps' ready() (e.g. dcim.ready() → Device._meta._relation_tree
# → apps.get_models()) return only static models rather than crashing on
# ContentType lookups. We call apps.clear_cache() so the next
# _relation_tree access recomputes with the full COT model set.
global _app_ready
_app_ready = True
from django.apps import apps as django_apps
django_apps.clear_cache()

super().ready()

def get_model(self, model_name, require_ready=True):
Expand Down Expand Up @@ -266,6 +285,16 @@ def get_models(self, include_auto_created=False, include_swapped=False):
"ignore", category=UserWarning, message=".*database.*"
)

# Skip dynamic model generation until ready() has completed.
# Other apps' ready() calls (e.g. dcim) trigger _relation_tree →
# apps.get_models() before our ready() runs. At that point _model_cache
# is empty, so get_model() would regenerate every COT from scratch —
# including ContentType DB lookups that may fail. After our ready()
# finishes, _app_ready is True and get_model() returns cached models
# without any ContentType lookups.
if not _app_ready:
return

# Skip custom object type model loading if dynamic models can't be created yet
if self.should_skip_dynamic_model_creation():
return
Expand Down
12 changes: 12 additions & 0 deletions netbox_custom_objects/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,14 @@ def get_field_data(self, obj):
def get_serializer_class(model, skip_object_fields=False):
model_fields = model.custom_object_type.fields.all()

# Fields skipped during model generation (e.g. broken/null related_object_type_id)
# won't be present on the model. Build a name set from safe list attributes so
# we can skip absent fields consistently in both loops below.
model_field_names = {
f.name
for f in list(model._meta.local_fields) + list(model._meta.local_many_to_many)
}

# Create field list including all necessary fields
base_fields = ["id", "url", "display", "created", "last_updated", "tags"]

Expand All @@ -275,6 +283,8 @@ def get_serializer_class(model, skip_object_fields=False):
# Only include custom field names that will actually be added to the serializer
custom_field_names = []
for field in model_fields:
if field.name not in model_field_names:
continue # excluded during model generation (e.g. broken FK)
if skip_object_fields and field.type in [
CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT
]:
Expand Down Expand Up @@ -383,6 +393,8 @@ def update(self, instance, validated_data):
attrs["get__context"] = get__context

for field in model_fields:
if field.name not in model_field_names:
continue # excluded during model generation (e.g. broken FK)
if skip_object_fields and field.type in [
CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT
]:
Expand Down
2 changes: 2 additions & 0 deletions netbox_custom_objects/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def get(self, request, *args, **kwargs):
urlpatterns = [
path("", include(router.urls)),
path("linked-objects/", views.LinkedObjectsView.as_view(), name="linked-objects"),
path("schema/preview/", views.SchemaPreviewView.as_view(), name="schema-preview"),
path("schema/apply/", views.SchemaApplyView.as_view(), name="schema-apply"),
path("<str:custom_object_type>/", custom_object_list, name="customobject-list"),
path(
"<str:custom_object_type>/<int:pk>/",
Expand Down
245 changes: 243 additions & 2 deletions netbox_custom_objects/api/views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import json
import logging
from pathlib import Path

from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema_view, extend_schema
from extras.choices import CustomFieldTypeChoices
from rest_framework import status
try:
from netbox.api.viewsets import ETagMixin # NetBox 4.6+
except ImportError:
Expand All @@ -13,14 +18,83 @@ class ETagMixin: # pragma: no cover – NetBox < 4.6 shim
from rest_framework.routers import APIRootView
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import PermissionDenied, ValidationError

from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired, TokenWritePermission

from netbox_custom_objects.filtersets import get_filterset_class
from netbox_custom_objects.models import CustomObjectType, CustomObjectTypeField
from netbox_custom_objects.utilities import is_in_branch

from . import serializers

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Schema document helpers
# ---------------------------------------------------------------------------

_SCHEMA_FILE = Path(__file__).parent.parent / "schema" / "cot_schema_v1.json"

try:
import jsonschema as _jsonschema
with open(_SCHEMA_FILE) as _f:
_COT_SCHEMA = json.load(_f)
_VALIDATOR = _jsonschema.Draft202012Validator(_COT_SCHEMA)
_HAS_JSONSCHEMA = True
except (ImportError, OSError, json.JSONDecodeError) as exc:
logger.warning("COT schema validation unavailable: %s", exc)
_HAS_JSONSCHEMA = False
_VALIDATOR = None


def _validate_schema_doc(schema_doc: dict) -> None:
"""
Validate *schema_doc* against the COT schema v1 JSON Schema.

Raises ``ValidationError`` (DRF 400) if validation fails or if
``jsonschema`` is not installed.
"""
if not _HAS_JSONSCHEMA:
# Can't validate without jsonschema; allow the request through and
# let the comparator / executor surface any structural problems.
return
errors = sorted(_VALIDATOR.iter_errors(schema_doc), key=lambda e: list(e.path))
if errors:
raise ValidationError({
"schema_errors": [
{"path": list(e.path), "message": e.message}
for e in errors[:10] # cap at 10 to avoid overwhelming responses
]
})


def _serialize_field_change(fc) -> dict:
result = {
"op": fc.op.value,
"schema_id": fc.schema_id,
"db_name": fc.db_name,
"schema_def": fc.schema_def,
}
if fc.changed_attrs:
# Tuples are not JSON-serialisable; convert to lists.
result["changed_attrs"] = {k: list(v) for k, v in fc.changed_attrs.items()}
return result


def _serialize_diff(diff) -> dict:
return {
"slug": diff.slug,
"name": diff.name,
"is_new": diff.is_new,
"has_changes": diff.has_changes,
"has_destructive_changes": diff.has_destructive_changes,
"cot_changes": {k: list(v) for k, v in diff.cot_changes.items()},
"field_changes": [_serialize_field_change(fc) for fc in diff.field_changes],
"warnings": diff.warnings,
}


# Constants
BRANCH_ACTIVE_ERROR_MESSAGE = _("Please switch to the main branch to perform this operation.")

Expand Down Expand Up @@ -190,7 +264,7 @@ def get(self, request, *args, **kwargs):

results = []
for field in fields:
custom_object_model = field.custom_object_type.get_model(no_cache=True)
custom_object_model = field.custom_object_type.get_model()

if field.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
m2m_field = custom_object_model._meta.get_field(field.name)
Expand All @@ -213,3 +287,170 @@ def get(self, request, *args, **kwargs):
})

return Response({'count': len(results), 'results': results})


class SchemaPreviewView(APIView):
"""
Preview the diff that would result from applying a COT schema document.

Accepts a ``POST`` request whose body is a schema document conforming to
``cot_schema_v1.json``. Returns a structured diff for every COT in the
document without making any DB changes.

## Request body

{
"schema_version": "1",
"types": [ ... ]
}

## Response (200)

{
"diffs": [
{
"slug": "my-cot",
"name": "my_cot",
"is_new": false,
"has_changes": true,
"has_destructive_changes": false,
"cot_changes": {"description": ["old", "new"]},
"field_changes": [
{
"op": "add",
"schema_id": 5,
"db_name": null,
"schema_def": { ... }
}
],
"warnings": []
}
]
}
"""

permission_classes = [IsAuthenticatedOrLoginNotRequired]

def post(self, request, *args, **kwargs):
# Deferred import: the schema package imports Django models at module level, which
# triggers app-registry access before it is ready if imported at the top of views.py.
from netbox_custom_objects.schema.comparator import diff_document # noqa: PLC0415

schema_doc = request.data
_validate_schema_doc(schema_doc)
diffs = diff_document(schema_doc)
return Response({"diffs": [_serialize_diff(d) for d in diffs]})


class SchemaApplyView(APIView):
"""
Apply a COT schema document to the live DB.

Accepts a ``POST`` request whose body wraps a schema document with an
optional ``allow_destructive`` flag. The document is diffed against the
current DB state and all changes are applied atomically. The applied
diffs are returned in the response.

## Request body

{
"allow_destructive": false,
"schema": {
"schema_version": "1",
"types": [ ... ]
}
}

``allow_destructive`` defaults to ``false``. Set it to ``true`` to
permit ``REMOVE`` field operations (which drop DB columns).

## Response (200)

{
"applied": true,
"diffs": [ ... ]
}

## Error responses

**409 Conflict** — the document contains ``REMOVE`` operations and
``allow_destructive`` was not set:

{
"error": "destructive_changes",
"detail": "Schema contains destructive ...",
"destructive_slugs": ["my-cot"]
}

**400 Bad Request** — circular COT dependency, unresolvable FK target,
or invalid schema document structure.

Unexpected DB errors (e.g. ``IntegrityError`` from a constraint violation
unrelated to the COT schema logic) are not caught and will surface as
**500 Internal Server Error**. The entire apply is wrapped in
``transaction.atomic()``, so any such failure leaves the DB unchanged.
"""

permission_classes = [IsAuthenticatedOrLoginNotRequired, TokenWritePermission]

def post(self, request, *args, **kwargs):
# TODO: Schema apply is blocked while in a branch context because the executor
# performs direct DDL (ALTER/DROP TABLE) that is not branch-aware. When branching
# is extended to support schema operations, remove this guard and wire up the
# appropriate branch-scoped apply path.
if is_in_branch():
raise ValidationError(BRANCH_ACTIVE_ERROR_MESSAGE)

if not (
request.user.has_perm('netbox_custom_objects.add_customobjecttype') and
request.user.has_perm('netbox_custom_objects.change_customobjecttype')
):
raise PermissionDenied(
"You do not have permission to apply a schema document. "
"Both add and change permissions on CustomObjectType are required."
)

# Deferred import: same app-registry concern as the comparator import above.
from netbox_custom_objects.schema.executor import ( # noqa: PLC0415
apply_document,
CircularDependencyError,
DestructiveChangesError,
UnknownChoiceSetError,
UnknownFieldTypeError,
UnknownObjectTypeError,
)

allow_destructive = request.data.get("allow_destructive", False)
if not isinstance(allow_destructive, bool):
raise ValidationError({"allow_destructive": _("'allow_destructive' must be a boolean.")})
schema_doc = request.data.get("schema")
if not isinstance(schema_doc, dict):
raise ValidationError(
{"schema": _("A 'schema' object containing the COT schema document is required.")}
)

_validate_schema_doc(schema_doc)

try:
diffs = apply_document(schema_doc, allow_destructive=allow_destructive)
except DestructiveChangesError as exc:
return Response(
{
"error": "destructive_changes",
"detail": str(exc),
"destructive_slugs": [d.slug for d in exc.diffs],
},
status=status.HTTP_409_CONFLICT,
)
except CircularDependencyError as exc:
return Response(
{"error": "circular_dependency", "detail": str(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
except (UnknownChoiceSetError, UnknownFieldTypeError, UnknownObjectTypeError) as exc:
return Response(
{"error": "unresolvable_reference", "detail": str(exc)},
status=status.HTTP_400_BAD_REQUEST,
)

return Response({"applied": True, "diffs": [_serialize_diff(d) for d in diffs]})
2 changes: 1 addition & 1 deletion netbox_custom_objects/comparator.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

if TYPE_CHECKING:
from django.contrib.contenttypes.models import ContentType
from netbox_custom_objects.schema_format import (
from netbox_custom_objects.schema.format import (
CUSTOM_OBJECTS_APP_LABEL_SLUG,
FIELD_DEFAULTS,
FIELD_TYPE_ATTRS,
Expand Down
Loading
Loading