Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
c245008
#404 add branching support
arthanson Apr 20, 2026
81565b6
#404 check for branch drift
arthanson Apr 20, 2026
ee41853
Merge branch 'main' into 404-branching
arthanson Apr 20, 2026
3f5e925
remove branch warning
arthanson Apr 20, 2026
4531551
get merge working
arthanson Apr 20, 2026
e425d0c
get merge working
arthanson Apr 20, 2026
62d6208
Merge branch 'feature' into 404-branching
arthanson Apr 20, 2026
de58404
test updates
arthanson Apr 20, 2026
bbb6292
add branching tests
arthanson Apr 21, 2026
a9c0219
add branching tests
arthanson Apr 21, 2026
f24dcaa
fix tests
arthanson Apr 21, 2026
ed9ec9e
refactor
arthanson Apr 21, 2026
8cd04a0
test cleanup
arthanson Apr 21, 2026
0aed016
test cleanup
arthanson Apr 21, 2026
f47ac66
test cleanup
arthanson Apr 21, 2026
539416d
update drift check
arthanson Apr 21, 2026
549f267
cleanup
arthanson Apr 21, 2026
493aacb
cleanup
arthanson Apr 21, 2026
0480531
cleanup
arthanson Apr 21, 2026
a3b0d8a
fix squash merge
arthanson Apr 21, 2026
3836669
add tests
arthanson Apr 21, 2026
1b1584c
update changelog entries on rename, remove branch migrate
arthanson Apr 23, 2026
9f8def8
fix merge conflicts
arthanson Apr 23, 2026
ba8eda3
cleanup
arthanson Apr 23, 2026
993fae7
refactor
arthanson Apr 23, 2026
5431a38
freeze db column name
arthanson Apr 23, 2026
44f834a
freeze db column name
arthanson Apr 23, 2026
fe534f2
freeze db column name
arthanson Apr 24, 2026
df61efb
freeze db column name
arthanson Apr 24, 2026
83246cd
merge feature
arthanson May 1, 2026
f12e4a8
cleanup
arthanson May 1, 2026
7c1f4e0
cleanup
arthanson May 1, 2026
754875e
add test for squash merge
arthanson May 1, 2026
8aeda5b
fix test failures
arthanson May 1, 2026
2aa0ffa
fix test failures
arthanson May 1, 2026
d345780
fix tests
arthanson May 1, 2026
d1da3e0
cleanup
arthanson May 1, 2026
a9c7cb5
cleanup
arthanson May 1, 2026
c1791c1
merge feature and remove additional warnings on branching
arthanson May 7, 2026
5e90558
remove branching monkey patch
arthanson May 7, 2026
ee53019
test fixes
arthanson May 7, 2026
793271e
changes to get tests workign
arthanson May 8, 2026
6d84fe0
changes for m2m
arthanson May 8, 2026
6bfba4f
fixes for testing
arthanson May 8, 2026
19acd83
Merge branch 'feature' into 404-branching
arthanson May 8, 2026
ac0de3b
add missing file
arthanson May 8, 2026
726ec68
cleanup
arthanson May 8, 2026
051572d
cleanup
arthanson May 8, 2026
b36720f
refactor
arthanson May 12, 2026
8ba84b7
Merge branch 'feature' into 404-branching
arthanson May 12, 2026
aaf2bd4
update functio name
arthanson May 19, 2026
372ddcc
use registration function
arthanson May 19, 2026
a01f9c8
cleanup
arthanson May 20, 2026
91316ed
add version checks to check framework
arthanson May 21, 2026
caf09cb
cleanup
arthanson May 21, 2026
c393f4d
cleanup
arthanson May 21, 2026
d72df77
cleanup
arthanson May 21, 2026
51ebb1c
add tests
arthanson May 21, 2026
671ceaa
delete COT
arthanson May 21, 2026
3cf98ff
cleanup
arthanson May 21, 2026
766a060
cleanup
arthanson May 21, 2026
23b8e33
polymorphic fields
arthanson May 22, 2026
b59097d
use signal handler for branching
arthanson May 22, 2026
e7af7b4
cleanup
arthanson May 22, 2026
c164269
cleanup
arthanson May 22, 2026
d82308b
remove check framework
arthanson May 22, 2026
e412002
cleanup
arthanson May 22, 2026
3cc11bd
cleanup
arthanson May 22, 2026
4f32f5f
cleanup
arthanson May 22, 2026
dc8aac7
cleanup
arthanson May 22, 2026
4265f5c
fix from branching changes
arthanson May 27, 2026
85b1397
merge feature/main
arthanson May 27, 2026
2f46e36
merge feature/main
arthanson May 27, 2026
a943c10
merge feature
arthanson May 29, 2026
5528df5
test fix
arthanson May 29, 2026
a8ab5f8
merge feature
arthanson May 29, 2026
40a789f
merge feature
arthanson May 29, 2026
ffcd972
fix tests
arthanson May 29, 2026
0a6ff73
update query counts
arthanson May 29, 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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Defer all version pins to `pyproject.toml` and `netbox_custom_objects/__init__.p
│ ├── tables.py — django-tables2 tables for list views.
│ ├── template_content.py — PluginTemplateExtension registrations.
│ ├── urls.py — Web UI URL routing (80+ routes).
│ ├── utilities.py — AppsProxy, generate_model(), get_viewname(), is_in_branch().
│ ├── utilities.py — AppsProxy, generate_model(), get_viewname().
│ ├── views.py — All UI views.
│ ├── api/
│ │ ├── serializers.py — get_serializer_class() + static serializers.
Expand Down Expand Up @@ -134,7 +134,7 @@ Multi-object fields create a separate through table (`custom_objects_<cot_id>_<f
| `netbox_custom_objects/__init__.py` | PluginConfig, migration detection, ObjectSelectorView patch, dynamic model registration on startup |
| `netbox_custom_objects/models.py` | `CustomObject` (abstract base), `CustomObjectType`, `CustomObjectTypeField`, signal handlers |
| `netbox_custom_objects/field_types.py` | Pluggable field type system |
| `netbox_custom_objects/utilities.py` | `generate_model()`, `AppsProxy`, `is_in_branch()` |
| `netbox_custom_objects/utilities.py` | `generate_model()`, `AppsProxy` |
| `netbox_custom_objects/jobs.py` | `ReindexCustomObjectTypeJob` |
| `netbox_custom_objects/api/views.py` | Dynamic ViewSet generation, `LinkedObjectsView` |
| `netbox_custom_objects/api/serializers.py` | `get_serializer_class()` for dynamic models |
Expand Down
14 changes: 0 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,6 @@ $ ./manage.py migrate
sudo systemctl restart netbox netbox-rq
```

> [!NOTE]
> If you are using NetBox Custom Objects with NetBox Branching, you need to insert the following into your `configuration.py`. See the docs for a full description of how NetBox Custom Objects currently works with NetBox Branching.

```
PLUGINS_CONFIG = {
'netbox_branching': {
'exempt_models': [
'netbox_custom_objects.customobjecttype',
'netbox_custom_objects.customobjecttypefield',
],
},
}
```

## Known Limitations

NetBox Custom Objects is now Generally Available which means you can use it in production and migrations to future versions will work. There are many upcoming features including GraphQL support - the best place to see what's on the way is the [issues](https://github.com/netboxlabs/netbox-custom-objects/issues) list on the GitHub repository.
7 changes: 7 additions & 0 deletions docs/branching.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Using NetBox Custom Objects with NetBox Branching

When using Custom Objects together with NetBox Branching, the following minimum versions are required:

- NetBox >= 4.6.2
- netbox-branching >= 1.1.0

If you do not use branching, the standard compatibility matrix in `COMPATIBILITY.md` applies.

As of version 0.4.0, Custom Objects is _compatible_ with [NetBox Branching](https://netboxlabs.com/docs/extensions/branching/), but not yet fully supported. Users can safely run both plugins together, but there are some caveats to be aware of. See below for how each Custom Objects model interacts with NetBox Branching.

!!! note
Expand Down
118 changes: 109 additions & 9 deletions netbox_custom_objects/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import contextvars
import logging
import sys
import warnings

Expand All @@ -12,6 +13,8 @@
from .constants import APP_LABEL as APP_LABEL
from .utilities import extract_cot_id_from_model_name, install_clear_cache_suppressor

logger = logging.getLogger(__name__)

# Context variable to track if we're currently running migrations
_is_migrating = contextvars.ContextVar('is_migrating', default=False)

Expand All @@ -27,6 +30,11 @@
# access recomputes with the full set of COT models.
_app_ready = False

# Guards ``super().ready()`` against the duplicate-registration check in
# NetBox's ``register_serializer_resolver`` (triggered by ``serializer_resolver``
# on this PluginConfig).
_super_ready_called = False


def _migration_started(sender, **kwargs):
"""Signal handler for pre_migrate - sets the migration flag."""
Expand All @@ -40,6 +48,78 @@ def _migration_finished(sender, **kwargs):
_migrations_checked = None


# Guards the netbox-branching hook setup against duplicate registration if
# ``ready()`` runs more than once (e.g. test isolation paths that reset the
# app registry). Covers signal connects and the netbox-branching
# ``register_*`` functions, which do not all dedupe internally.
_branching_hooks_registered = False


def _reset_deferred_co_field_data(sender, **kwargs):
"""Module-level receiver so Django's ``Signal.connect`` dedupes it across
repeat ``ready()`` invocations (a closure would have a fresh id each call).
"""
from netbox_custom_objects.models import _deferred_co_field_data
_deferred_co_field_data.set(None)


def _register_branching_hooks_once():
"""Register netbox-branching integration hooks at most once per process.

Wraps the branching-resolver, objectchange-field-migrator, deferred-data
reset receivers, and squash-dependency-graph receiver. Connect both pre-
and post- merge/sync/revert so the deferred-data reset runs even when the
operation raises (post-signals only fire on success). ``weak=False`` keeps
the receivers alive past the end of ``ready()``.
"""
global _branching_hooks_registered
if _branching_hooks_registered:
return

try:
from netbox_branching.signals import (
pre_merge, post_merge,
pre_sync, post_sync,
pre_revert, post_revert,
)
except ImportError:
return

for sig in (pre_merge, post_merge, pre_sync, post_sync, pre_revert, post_revert):
sig.connect(_reset_deferred_co_field_data, weak=False)

try:
from netbox_branching.utilities import (
register_branching_resolver,
register_objectchange_field_migrator,
)
from .branching import (
objectchange_field_migrator,
supports_branching_resolver,
)
register_branching_resolver(supports_branching_resolver)
register_objectchange_field_migrator(objectchange_field_migrator)
# Subscribe to the squash dependency-graph signal so CO-specific
# edges (M2M targets, polymorphic-M2M sidecar) get added before
# topological ordering. Skipped silently on older netbox-branching
# that doesn't expose the signal yet.
try:
from netbox_branching.signals import (
squash_dependency_graph_built,
)
from .branching import add_custom_object_dependencies
squash_dependency_graph_built.connect(
add_custom_object_dependencies,
weak=False,
)
except ImportError:
pass
except ImportError:
pass

_branching_hooks_registered = True


# Module-level flag so the heal runs at most once per process invocation even
# though post_migrate fires once per installed app.
_heal_ran = False
Expand All @@ -64,12 +144,6 @@ def _heal_mixin_columns(sender, **kwargs):
if any(cmd in sys.argv for cmd in ("makemigrations", "collectstatic")):
return

# Set the flag *before* running so that subsequent post_migrate firings
# (one per installed app) are no-ops even if the first attempt raises.
# A failure here will not be retried in the same process; operators can
# run 'manage.py upgrade_custom_objects' manually if needed.
_heal_ran = True

try:
from netbox_custom_objects.mixin_migration import heal_all_cots # noqa: PLC0415
heal_all_cots(verbosity=kwargs.get("verbosity", 1))
Expand All @@ -78,6 +152,13 @@ def _heal_mixin_columns(sender, **kwargs):
logging.getLogger(__name__).exception(
"upgrade_custom_objects: unexpected error during mixin drift check"
)
# Leave _heal_ran False so a subsequent post_migrate firing (or a
# manual 'manage.py upgrade_custom_objects') gets another attempt.
return

# Only mark complete on success so a transient failure can be retried by
# the next post_migrate firing in this process.
_heal_ran = True


def _patch_object_selector_view():
Expand Down Expand Up @@ -133,6 +214,9 @@ class CustomObjectsPluginConfig(PluginConfig):
}
required_settings = []
template_extensions = "template_content.template_extensions"
# Resolves dynamic CO models (table{n}model) to on-the-fly serializers —
# they have no importable path at the conventional location.
serializer_resolver = "api.serializers.serializer_resolver"

@staticmethod
def should_skip_dynamic_model_creation():
Expand Down Expand Up @@ -216,6 +300,15 @@ def should_skip_dynamic_model_creation():
# Always clear the recursion flag
_checking_migrations = False

def _call_super_ready_once(self):
"""Call ``super().ready()`` once; subsequent calls are no-ops.
``register_serializer_resolver`` rejects duplicates."""
global _super_ready_called
if _super_ready_called:
return
super().ready()
_super_ready_called = True

def ready(self):
# Install the thread-safe apps.clear_cache wrapper before any dynamic
# model is registered (must happen exactly once, before get_model() runs).
Expand All @@ -234,6 +327,13 @@ def ready(self):
# Patch ObjectSelectorView to support dynamically-generated custom object models
_patch_object_selector_view()

# Register netbox-branching integration hooks (deferred-data reset
# receivers, branchable resolver, ObjectChange field-name migrator,
# squash dependency-graph receiver). Guarded so the plugin still
# works without netbox-branching, and so repeat ready() invocations
# don't accumulate duplicate handlers.
_register_branching_hooks_once()

# Suppress warnings about database calls during app initialization
with warnings.catch_warnings():
warnings.filterwarnings(
Expand All @@ -245,7 +345,7 @@ def ready(self):

# Skip database calls if dynamic models can't be created yet
if self.should_skip_dynamic_model_creation():
super().ready()
self._call_super_ready_once()
return

try:
Expand Down Expand Up @@ -282,7 +382,7 @@ def ready(self):
except (ProgrammingError, OperationalError):
# DB schema is incomplete (unapplied migrations). Skip dynamic
# model registration — it will happen after migrations finish.
super().ready()
self._call_super_ready_once()
return

# Signal that ready() has fully completed. get_models() checks this flag
Expand All @@ -296,7 +396,7 @@ def ready(self):
from django.apps import apps as django_apps
django_apps.clear_cache()

super().ready()
self._call_super_ready_once()

def get_model(self, model_name, require_ready=True):
self.apps.check_apps_ready()
Expand Down
54 changes: 21 additions & 33 deletions netbox_custom_objects/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from core.models import ObjectType
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.utils import OperationalError, ProgrammingError
from django.urls import NoReverseMatch
from django.utils.translation import gettext_lazy as _
from extras.choices import CustomFieldTypeChoices
Expand Down Expand Up @@ -694,7 +693,8 @@ def validate(self, data):
)

# Register the FULL serializer as a module attribute so NetBox's import_string()
# and the module-level __getattr__ fallback can find it.
# can find it (the serializer_resolver below generates on demand; this keeps
# any direct import-path lookups working too).
# The partial variant (skip_object_fields=True) is used only as a nested field
# descriptor inside another serializer class and must NOT be stored on the module
# — doing so would silently replace the full serializer with an incomplete one
Expand All @@ -707,35 +707,23 @@ def validate(self, data):
return serializer


def __getattr__(name):
"""
Module-level lazy resolution for Table{N}ModelSerializer attributes (PEP 562).

NetBox's get_serializer_for_model() resolves serializers via
import_string("netbox_custom_objects.api.serializers.TableNModelSerializer"),
which ultimately calls getattr(module, name). That lookup hits this hook
when the attribute has not yet been registered — for example in a worker
whose ready() ran against an empty database, or in any worker that started
before a given COT was created.

Generating on demand here means SerializerNotFound is never raised just
because startup-time registration was skipped or missed (issue #370).
The generated serializer is stored via setattr inside get_serializer_class(),
so subsequent lookups return it directly from __dict__ without re-entering
this hook.
def serializer_resolver(model, prefix=''):
"""Resolve dynamic CO models (``table{n}model``) to on-the-fly serializers.

Called by ``utilities.api.get_serializer_for_model`` before its default
import-path lookup. Returns ``None`` for non-CO models so the default
lookup runs (including this plugin's static CustomObjectType serializer).

This supersedes the import-path/``__getattr__`` fallback approach: because
the resolver runs before any import path is built for a CO model, the
serializer is always generated on demand and ``SerializerNotFound`` is never
raised just because startup-time registration was skipped or missed
(issue #370).
"""
match = re.match(r'^Table(\d+)ModelSerializer$', name)
if match:
cot_id = int(match.group(1))
try:
obj = CustomObjectType.objects.get(pk=cot_id)
model = obj.get_model()
return get_serializer_class(model)
except (CustomObjectType.DoesNotExist, ProgrammingError, OperationalError, LookupError):
pass
except Exception:
logger.warning(
"Unexpected error generating serializer for %r; serializer will not be available",
name, exc_info=True,
)
raise AttributeError(f"module '{__name__}' has no attribute {name!r}")
if (
getattr(model, '_meta', None)
and model._meta.app_label == 'netbox_custom_objects'
and _TABLE_MODEL_PATTERN.match(model.__name__)
):
return get_serializer_class(model)
return None
26 changes: 8 additions & 18 deletions netbox_custom_objects/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ class ETagMixin: # pragma: no cover – NetBox < 4.6 shim
UnknownFieldTypeError,
UnknownObjectTypeError,
)
from netbox_custom_objects.utilities import is_in_branch

from . import serializers

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -102,10 +100,6 @@ def _serialize_diff(diff) -> dict:
}


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


class RootView(APIRootView):
def get_view_name(self):
return "CustomObjects"
Expand Down Expand Up @@ -158,14 +152,9 @@ def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)

def create(self, request, *args, **kwargs):
if is_in_branch():
raise ValidationError(BRANCH_ACTIVE_ERROR_MESSAGE)
return super().create(request, *args, **kwargs)

def update(self, request, *args, **kwargs):
if is_in_branch():
raise ValidationError(BRANCH_ACTIVE_ERROR_MESSAGE)

# Replicate DRF's UpdateModelMixin.update() so we can snapshot the instance
# before the serializer is constructed. Calling super().update() would invoke
# get_object() a second time and return a fresh, un-snapshotted instance.
Expand Down Expand Up @@ -415,13 +404,14 @@ class SchemaApplyView(APIView):
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)

# Branch context: this endpoint no longer rejects requests with an active
# branch. Schema-editor calls inside ``apply_document`` route through
# ``_get_schema_connection()`` in models.py, which selects the active
# branch's connection when one is set. The resulting DDL therefore lands
# in the branch's PostgreSQL schema, and the CustomObjectType /
# CustomObjectTypeField writes flow through netbox-branching's router.
# See ``_schema_add_field`` / ``_schema_remove_field`` / ``_schema_alter_field``
# and ``CustomObjectType.save`` for the routing details.
if not (
request.user.has_perm('netbox_custom_objects.add_customobjecttype') and
request.user.has_perm('netbox_custom_objects.change_customobjecttype')
Expand Down
Loading