Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [4.7.0.1] - 2026-09-04

### Changed
- NetBox 4.7 compatibility: `registry['models']` was removed in 4.7, so model
registration is now detected via `registry['views']` (a model is considered
registered when all feature views `register_models()` would add for it are
present).
- Iterate `registry['plugins']['installed']` instead of `settings.PLUGINS`, so plugins that
NetBox 4.7 skipped for an incompatible `min_version`/`max_version` no longer produce
"Error resolving plugin" tracebacks at startup.
- Version pins updated to `4.7.0`–`4.7.99`; Django classifier bumped to `6.1`.

## [4.6.0.2] - 2026-08-25

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ A NetBox plugin that dynamically reloads plugins without requiring a server rest
| 4.5.0 - 4.5.3 | 4.5.0.1 |
| 4.5.4+ | 4.5.4.x |
| 4.6.x | 4.6.x |
| 4.7.x | 4.7.x |


**Version Format**: X.X.Y.Z where X.X = NetBox major.minor version (e.g., 4.5), Y = NetBox patch version (e.g., 0), and Z = plugin release increment
Expand Down
52 changes: 37 additions & 15 deletions netbox_plugin_reloader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ class NetboxPluginReloaderConfig(PluginConfig):
description = "Dynamically reload NetBox plugins without server restart"
version = __version__
base_url = "netbox-plugin-reloader"
min_version = "4.6.0"
max_version = "4.6.99"
min_version = "4.7.0"
max_version = "4.7.99"

def ready(self):
"""
Expand All @@ -37,18 +37,38 @@ def ready(self):

from core.models.object_types import ObjectType
from django.apps.registry import apps
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from extras.forms.model_forms import CustomFieldForm, TagForm
from netbox.models.features import register_models
from netbox.models.features import (
ChangeLoggingMixin,
ContactsMixin,
ImageAttachmentsMixin,
JobsMixin,
JournalingMixin,
SyncedDataMixin,
register_models,
)
from netbox.registry import registry
from utilities.forms.fields import ContentTypeMultipleChoiceField

# Only plugins NetBox actually loaded (those failing min/max_version are skipped by NetBox 4.7).
# Materialized because plugin_configs is iterated by both registration and deduplication
plugin_configs = list(self._iter_plugin_configs(settings.PLUGINS, apps))
plugin_configs = list(self._iter_plugin_configs(registry["plugins"]["installed"], apps))

# Mirrors the feature views register_models() adds (netbox/models/features.py)
feature_views = (
(ContactsMixin, "contacts"),
(JournalingMixin, "journal"),
(ChangeLoggingMixin, "changelog"),
(JobsMixin, "jobs"),
(ImageAttachmentsMixin, "image-attachments"),
(SyncedDataMixin, "sync"),
)

# Register missing plugin models
models_registered = self._register_missing_plugin_models(plugin_configs, registry, register_models)
models_registered = self._register_missing_plugin_models(
plugin_configs, registry, register_models, feature_views
)

# Deduplicate view registrations that may have accumulated during dynamic model registration
self._deduplicate_view_registrations(plugin_configs, registry)
Expand All @@ -70,7 +90,7 @@ def _iter_plugin_configs(self, plugin_list, app_registry):
except LookupError:
logger.exception("Error resolving plugin %s", plugin_name)

def _register_missing_plugin_models(self, plugin_configs, netbox_registry, model_register_function):
def _register_missing_plugin_models(self, plugin_configs, netbox_registry, model_register_function, feature_views):
"""
Registers plugin models that were not registered during initial application startup.

Expand All @@ -82,7 +102,7 @@ def _register_missing_plugin_models(self, plugin_configs, netbox_registry, model
try:
for model_class in app_config.get_models():
model_name = model_class._meta.model_name
if not self._is_model_registered(app_label, model_name, netbox_registry):
if not self._is_model_registered(model_class, app_label, model_name, netbox_registry, feature_views):
unregistered_models.append(model_class)
except Exception:
logger.exception("Error processing models for plugin %s", plugin_name)
Expand Down Expand Up @@ -126,16 +146,18 @@ def _deduplicate_view_registrations(self, plugin_configs, netbox_registry):
logger.debug("Removed %d duplicate view entries for %s.%s", removed, app_label, model_name)
views_registry[app_label][model_name] = deduped

def _is_model_registered(self, app_label, model_name, netbox_registry):
def _is_model_registered(self, model_class, app_label, model_name, netbox_registry, feature_views):
"""
Determines whether a model is registered in the NetBox registry.
Determines whether register_models() has already run for a model.

In NetBox 4.4+, we check if the model is in the registry['models'] structure.
The underlying dict is accessed directly (as NetBox core does) to avoid the
NetBox 4.6 FutureWarning on the deprecated 'models' registry key.
NetBox 4.7 removed registry['models'], so registration is inferred from registry['views']:
a model is registered if every feature view register_models() would add for it (based on
the feature mixins it subclasses) is already present by name. Models with no applicable
feature mixin have nothing to register and count as registered.
"""
models = dict.__getitem__(netbox_registry, "models").get(app_label, {})
return model_name in models
views = netbox_registry.get("views", {}).get(app_label, {}).get(model_name, [])
present = {view.get("name") for view in views}
return all(name in present for mixin, name in feature_views if issubclass(model_class, mixin))

def _refresh_form_field(self, form_class, feature_name, object_type_class, field_class, translation_function):
"""
Expand Down
2 changes: 1 addition & 1 deletion netbox_plugin_reloader/version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Version information."""

__version__ = "4.6.0.2"
__version__ = "4.7.0.1"
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def get_version(rel_path):
classifiers=[
'Intended Audience :: Developers',
'Framework :: Django',
'Framework :: Django :: 6.0',
'Framework :: Django :: 6.1',
'License :: OSI Approved :: Apache Software License',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.12',
Expand Down
69 changes: 46 additions & 23 deletions tests/test_plugin_reloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ def _make_instance():
INSTANCE = _make_instance()


class FakeJournalingMixin:
pass


class FakeChangeLoggingMixin:
pass


FEATURE_VIEWS = ((FakeJournalingMixin, "journal"), (FakeChangeLoggingMixin, "changelog"))


def _make_model(app_label, model_name, *bases):
"""Build a real class (issubclass needs one) standing in for a Django model."""
return type(model_name, bases, {"_meta": SimpleNamespace(model_name=model_name), "_app_label": app_label})


# ---------------------------------------------------------------------------
# _deduplicate_view_registrations
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -157,42 +173,38 @@ def test_mixed_duplicates_unique_nameless(self):
# _register_missing_plugin_models
# ---------------------------------------------------------------------------
class TestRegisterMissingPluginModels(unittest.TestCase):
def _make_model(self, app_label, model_name):
meta = SimpleNamespace(model_name=model_name)
return SimpleNamespace(_meta=meta, _app_label=app_label)

def _make_app_config(self, models):
config = MagicMock()
config.get_models.return_value = models
config.label = "test_app"
return config

def test_registers_unregistered_models(self):
model = self._make_model("test_app", "mymodel")
model = _make_model("test_app", "mymodel", FakeJournalingMixin)
config = self._make_app_config([model])
plugin_configs = [("test_plugin", config, "test_app")]
registry = {"models": {}}
registry = {"views": {}}
register_fn = MagicMock()

result = INSTANCE._register_missing_plugin_models(plugin_configs, registry, register_fn)
result = INSTANCE._register_missing_plugin_models(plugin_configs, registry, register_fn, FEATURE_VIEWS)

self.assertTrue(result)
register_fn.assert_called_once_with(model)

def test_skips_registered_models(self):
model = self._make_model("test_app", "mymodel")
model = _make_model("test_app", "mymodel", FakeJournalingMixin)
config = self._make_app_config([model])
plugin_configs = [("test_plugin", config, "test_app")]
registry = {"models": {"test_app": {"mymodel": True}}}
registry = {"views": {"test_app": {"mymodel": [{"name": "journal"}]}}}
register_fn = MagicMock()

result = INSTANCE._register_missing_plugin_models(plugin_configs, registry, register_fn)
result = INSTANCE._register_missing_plugin_models(plugin_configs, registry, register_fn, FEATURE_VIEWS)

self.assertFalse(result)
register_fn.assert_not_called()

def test_broken_plugin_skipped_others_continue(self):
good_model = self._make_model("good_app", "goodmodel")
good_model = _make_model("good_app", "goodmodel", FakeJournalingMixin)

broken_config = MagicMock()
broken_config.get_models.side_effect = RuntimeError("broken")
Expand All @@ -205,21 +217,21 @@ def test_broken_plugin_skipped_others_continue(self):
("broken_plugin", broken_config, "broken_app"),
("good_plugin", good_config, "good_app"),
]
registry = {"models": {}}
registry = {"views": {}}
register_fn = MagicMock()

result = INSTANCE._register_missing_plugin_models(plugin_configs, registry, register_fn)
result = INSTANCE._register_missing_plugin_models(plugin_configs, registry, register_fn, FEATURE_VIEWS)

self.assertTrue(result)
register_fn.assert_called_once_with(good_model)

def test_returns_false_when_nothing_to_register(self):
config = self._make_app_config([])
plugin_configs = [("test_plugin", config, "test_app")]
registry = {"models": {}}
registry = {"views": {}}
register_fn = MagicMock()

result = INSTANCE._register_missing_plugin_models(plugin_configs, registry, register_fn)
result = INSTANCE._register_missing_plugin_models(plugin_configs, registry, register_fn, FEATURE_VIEWS)

self.assertFalse(result)
register_fn.assert_not_called()
Expand All @@ -229,17 +241,28 @@ def test_returns_false_when_nothing_to_register(self):
# _is_model_registered
# ---------------------------------------------------------------------------
class TestIsModelRegistered(unittest.TestCase):
def test_model_present(self):
registry = {"models": {"myapp": {"mymodel": True}}}
self.assertTrue(INSTANCE._is_model_registered("myapp", "mymodel", registry))
def _call(self, model, registry):
return INSTANCE._is_model_registered(model, "myapp", "mymodel", registry, FEATURE_VIEWS)

def test_all_feature_views_present(self):
model = _make_model("myapp", "mymodel", FakeJournalingMixin, FakeChangeLoggingMixin)
registry = {"views": {"myapp": {"mymodel": [{"name": "journal"}, {"name": "changelog"}, {"name": "edit"}]}}}
self.assertTrue(self._call(model, registry))

def test_model_absent(self):
registry = {"models": {"myapp": {"othermodel": True}}}
self.assertFalse(INSTANCE._is_model_registered("myapp", "mymodel", registry))
def test_one_feature_view_missing(self):
model = _make_model("myapp", "mymodel", FakeJournalingMixin, FakeChangeLoggingMixin)
registry = {"views": {"myapp": {"mymodel": [{"name": "journal"}]}}}
self.assertFalse(self._call(model, registry))

def test_app_label_absent(self):
registry = {"models": {}}
self.assertFalse(INSTANCE._is_model_registered("myapp", "mymodel", registry))
model = _make_model("myapp", "mymodel", FakeJournalingMixin)
registry = {"views": {}}
self.assertFalse(self._call(model, registry))

def test_no_applicable_mixin_counts_as_registered(self):
model = _make_model("myapp", "mymodel")
registry = {"views": {}}
self.assertTrue(self._call(model, registry))


# ---------------------------------------------------------------------------
Expand Down
Loading