diff --git a/CHANGELOG.md b/CHANGELOG.md index a88b66a..3672421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f1f4697..944ab40 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/netbox_plugin_reloader/__init__.py b/netbox_plugin_reloader/__init__.py index c80576d..b4201fb 100644 --- a/netbox_plugin_reloader/__init__.py +++ b/netbox_plugin_reloader/__init__.py @@ -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): """ @@ -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) @@ -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. @@ -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) @@ -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): """ diff --git a/netbox_plugin_reloader/version.py b/netbox_plugin_reloader/version.py index a99cff6..d7d74ec 100644 --- a/netbox_plugin_reloader/version.py +++ b/netbox_plugin_reloader/version.py @@ -1,3 +1,3 @@ """Version information.""" -__version__ = "4.6.0.2" +__version__ = "4.7.0.1" diff --git a/setup.py b/setup.py index 94c3e03..9796ac2 100644 --- a/setup.py +++ b/setup.py @@ -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', diff --git a/tests/test_plugin_reloader.py b/tests/test_plugin_reloader.py index 7ecae97..76e75c5 100644 --- a/tests/test_plugin_reloader.py +++ b/tests/test_plugin_reloader.py @@ -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 # --------------------------------------------------------------------------- @@ -157,10 +173,6 @@ 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 @@ -168,31 +180,31 @@ def _make_app_config(self, models): 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") @@ -205,10 +217,10 @@ 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) @@ -216,10 +228,10 @@ def test_broken_plugin_skipped_others_continue(self): 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() @@ -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)) # ---------------------------------------------------------------------------