Skip to content

Commit ed27d11

Browse files
authored
Merge pull request #7 from CESNET/fix/defer-db-queries-to-first-request
Fix DB queries during app initialization (#4)
2 parents 4c2c049 + c9bbf6a commit ed27d11

3 files changed

Lines changed: 111 additions & 20 deletions

File tree

netbox_custom_objects_tab/views/__init__.py

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import threading
23

34
from django.apps import apps
45
from netbox.plugins import get_plugin_config
@@ -9,6 +10,18 @@
910

1011
logger = logging.getLogger("netbox_custom_objects_tab")
1112

13+
# ── Deferred initialisation (avoids DB queries during ready()) ───────────
14+
# Typed-tab registration queries CustomObjectTypeField and ContentType.
15+
# Running those queries in AppConfig.ready() triggers warnings from Django
16+
# ("Accessing the database during app initialization is discouraged") and
17+
# from netbox_branching ("Routing database query … before branching support
18+
# is initialized"). We defer that work to the first HTTP request via
19+
# Django's request_started signal. See GitHub issue #4.
20+
21+
_deferred_init_lock = threading.Lock()
22+
_deferred_init_done = False
23+
_deferred_config = {} # populated in register_tabs(), consumed in _deferred_typed_init()
24+
1225

1326
def _resolve_dynamic_custom_object_models():
1427
"""
@@ -162,10 +175,52 @@ def _deduplicate_registry():
162175
model_map[model_name] = deduped
163176

164177

178+
def _deferred_typed_init(sender=None, **kwargs):
179+
"""
180+
One-shot ``request_started`` handler that performs DB-dependent initialisation.
181+
182+
Registers typed tabs, injects CO URL patterns, and deduplicates the view
183+
registry. Runs exactly once on the first HTTP request, then disconnects
184+
itself so subsequent requests pay no overhead.
185+
"""
186+
global _deferred_init_done
187+
with _deferred_init_lock:
188+
if _deferred_init_done:
189+
return
190+
_deferred_init_done = True
191+
192+
from django.core.signals import request_started
193+
194+
request_started.disconnect(_deferred_typed_init, dispatch_uid="netbox_custom_objects_tab_deferred")
195+
196+
config = _deferred_config
197+
combined_models = config.get("combined_models", [])
198+
typed_labels = config.get("typed_labels", [])
199+
typed_weight = config.get("typed_weight", 2100)
200+
201+
typed_models = []
202+
if typed_labels:
203+
typed_models = _resolve_model_labels(typed_labels)
204+
register_typed_tabs(typed_models, typed_weight)
205+
206+
# Inject URL patterns for CO dynamic models (combined + typed).
207+
if any(m._meta.app_label == _CUSTOM_OBJECTS_APP for m in combined_models + typed_models):
208+
_inject_co_urls()
209+
210+
# Deduplicate the registry — netbox_custom_objects re-registers journal/changelog
211+
# on every get_model() cache miss, producing duplicate tabs.
212+
_deduplicate_registry()
213+
214+
165215
def register_tabs():
166216
"""
167217
Read plugin config and register both combined and typed tabs.
168218
Called from AppConfig.ready().
219+
220+
Combined tabs are registered immediately (no DB queries needed).
221+
Typed tabs and other DB-dependent work are deferred to the first HTTP
222+
request via the ``request_started`` signal to avoid DB access during
223+
app initialisation (GitHub issue #4).
169224
"""
170225
try:
171226
combined_labels = get_plugin_config("netbox_custom_objects_tab", "combined_models")
@@ -177,21 +232,22 @@ def register_tabs():
177232
logger.exception("Could not read netbox_custom_objects_tab plugin config")
178233
return
179234

235+
# Phase 1 — immediate: combined tabs do not query the database.
180236
combined_models = []
181237
if combined_labels:
182238
combined_models = _resolve_model_labels(combined_labels)
183239
register_combined_tabs(combined_models, combined_label, combined_weight)
184240

185-
typed_models = []
186-
if typed_labels:
187-
typed_models = _resolve_model_labels(typed_labels)
188-
register_typed_tabs(typed_models, typed_weight)
241+
# Phase 2 — deferred: typed tabs, CO URL injection, and registry dedup
242+
# all require DB access and must wait until the first request.
243+
_deferred_config.update(
244+
{
245+
"combined_models": combined_models,
246+
"typed_labels": typed_labels,
247+
"typed_weight": typed_weight,
248+
}
249+
)
189250

190-
# For any CO dynamic models we registered tabs for, inject URL patterns into
191-
# netbox_custom_objects.urls so that tab URL reversal works.
192-
if any(m._meta.app_label == _CUSTOM_OBJECTS_APP for m in combined_models + typed_models):
193-
_inject_co_urls()
251+
from django.core.signals import request_started
194252

195-
# Deduplicate the registry — netbox_custom_objects re-registers journal/changelog
196-
# on every get_model() cache miss, producing duplicate tabs.
197-
_deduplicate_registry()
253+
request_started.connect(_deferred_typed_init, dispatch_uid="netbox_custom_objects_tab_deferred")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "netbox-custom-objects-tab"
7-
version = "2.1.0"
7+
version = "2.1.1"
88
description = "NetBox plugin that adds a Custom Objects tab to object detail pages"
99
readme = "README.md"
1010
requires-python = ">=3.12"

tests/test_views_init.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,11 @@ def test_unknown_specific_model_logs_warning_no_exception(self, caplog):
5454

5555

5656
class TestRegisterTabs:
57-
def test_dispatches_combined_and_typed_tabs(self):
57+
def test_dispatches_combined_tabs_immediately_and_defers_typed(self):
58+
"""register_tabs() registers combined tabs in ready() and defers typed tabs."""
5859
from netbox_custom_objects_tab import views
5960

6061
combined_models = [MagicMock()]
61-
typed_models = [MagicMock()]
6262

6363
config_map = {
6464
"combined_models": ["dcim.device"],
@@ -70,14 +70,52 @@ def test_dispatches_combined_and_typed_tabs(self):
7070

7171
with (
7272
patch.object(views, "get_plugin_config", side_effect=lambda _plugin, key: config_map[key]),
73-
patch.object(views, "_resolve_model_labels", side_effect=[combined_models, typed_models]),
73+
patch.object(views, "_resolve_model_labels", return_value=combined_models),
7474
patch.object(views, "register_combined_tabs") as register_combined,
7575
patch.object(views, "register_typed_tabs") as register_typed,
76+
patch("django.core.signals.request_started") as mock_signal,
7677
):
7778
views.register_tabs()
7879

80+
# Combined tabs registered immediately
7981
register_combined.assert_called_once_with(combined_models, "Custom Objects", 2000)
80-
register_typed.assert_called_once_with(typed_models, 2100)
82+
# Typed tabs NOT called during register_tabs() — deferred to first request
83+
register_typed.assert_not_called()
84+
# Signal handler connected for deferred init
85+
mock_signal.connect.assert_called_once()
86+
87+
def test_deferred_init_dispatches_typed_tabs(self):
88+
"""_deferred_typed_init() resolves labels and registers typed tabs."""
89+
from netbox_custom_objects_tab import views
90+
91+
typed_models = [MagicMock()]
92+
typed_models[0]._meta.app_label = "ipam"
93+
94+
# Reset the deferred state so the handler can run
95+
views._deferred_init_done = False
96+
views._deferred_config.update(
97+
{
98+
"combined_models": [],
99+
"typed_labels": ["ipam.prefix"],
100+
"typed_weight": 2100,
101+
}
102+
)
103+
104+
try:
105+
with (
106+
patch.object(views, "_resolve_model_labels", return_value=typed_models),
107+
patch.object(views, "register_typed_tabs") as register_typed,
108+
patch.object(views, "_inject_co_urls"),
109+
patch.object(views, "_deduplicate_registry"),
110+
patch("django.core.signals.request_started"),
111+
):
112+
views._deferred_typed_init()
113+
114+
register_typed.assert_called_once_with(typed_models, 2100)
115+
finally:
116+
# Clean up global state
117+
views._deferred_init_done = False
118+
views._deferred_config.clear()
81119

82120
def test_skips_dispatch_when_configured_model_lists_are_empty(self):
83121
from netbox_custom_objects_tab import views
@@ -94,25 +132,22 @@ def test_skips_dispatch_when_configured_model_lists_are_empty(self):
94132
patch.object(views, "get_plugin_config", side_effect=lambda _plugin, key: config_map[key]),
95133
patch.object(views, "_resolve_model_labels") as resolve_labels,
96134
patch.object(views, "register_combined_tabs") as register_combined,
97-
patch.object(views, "register_typed_tabs") as register_typed,
135+
patch("django.core.signals.request_started"),
98136
):
99137
views.register_tabs()
100138

101139
resolve_labels.assert_not_called()
102140
register_combined.assert_not_called()
103-
register_typed.assert_not_called()
104141

105142
def test_config_exception_is_handled(self, caplog):
106143
from netbox_custom_objects_tab import views
107144

108145
with (
109146
patch.object(views, "get_plugin_config", side_effect=RuntimeError("boom")),
110147
patch.object(views, "register_combined_tabs") as register_combined,
111-
patch.object(views, "register_typed_tabs") as register_typed,
112148
):
113149
with caplog.at_level(logging.ERROR, logger="netbox_custom_objects_tab"):
114150
views.register_tabs()
115151

116152
register_combined.assert_not_called()
117-
register_typed.assert_not_called()
118153
assert any("Could not read netbox_custom_objects_tab plugin config" in r.message for r in caplog.records)

0 commit comments

Comments
 (0)