netbox-sqlquery 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ from django.contrib.auth import get_user_model
2
+ from django.test import TestCase
3
+
4
+ User = get_user_model()
5
+ from django.utils import timezone
6
+
7
+ from netbox_sqlquery.models import SavedQuery
8
+
9
+
10
+ class SavedQueryVisibilityTest(TestCase):
11
+
12
+ def setUp(self):
13
+ self.user1 = User.objects.create_user("user1", password="test")
14
+ self.user2 = User.objects.create_user("user2", password="test")
15
+ self.private_query = SavedQuery.objects.create(
16
+ name="Private", sql="SELECT 1", owner=self.user1,
17
+ visibility=SavedQuery.VISIBILITY_PRIVATE,
18
+ )
19
+ self.global_query = SavedQuery.objects.create(
20
+ name="Global", sql="SELECT 2", owner=self.user1,
21
+ visibility=SavedQuery.VISIBILITY_GLOBAL,
22
+ )
23
+ self.editable_query = SavedQuery.objects.create(
24
+ name="Editable", sql="SELECT 3", owner=self.user1,
25
+ visibility=SavedQuery.VISIBILITY_GLOBAL_EDITABLE,
26
+ )
27
+
28
+ def test_private_query_not_visible_to_other_user(self):
29
+ visible = SavedQuery.visible_to(self.user2)
30
+ self.assertNotIn(self.private_query, visible)
31
+
32
+ def test_global_query_visible_to_all_authenticated_users(self):
33
+ visible = SavedQuery.visible_to(self.user2)
34
+ self.assertIn(self.global_query, visible)
35
+
36
+ def test_global_editable_query_visible_to_all(self):
37
+ visible = SavedQuery.visible_to(self.user2)
38
+ self.assertIn(self.editable_query, visible)
39
+
40
+ def test_run_count_increments_on_execution(self):
41
+ self.private_query.run_count += 1
42
+ self.private_query.last_run = timezone.now()
43
+ self.private_query.save()
44
+ self.private_query.refresh_from_db()
45
+ self.assertEqual(self.private_query.run_count, 1)
@@ -0,0 +1,94 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ from django.contrib.auth import get_user_model
4
+ from django.test import TestCase, override_settings
5
+
6
+ User = get_user_model()
7
+
8
+ PLUGIN_CONFIG = {
9
+ "netbox_sqlquery": {
10
+ "require_superuser": True,
11
+ "max_rows": 1000,
12
+ "statement_timeout_ms": 10000,
13
+ "deny_tables": ["auth_user", "users_token", "users_userconfig"],
14
+ }
15
+ }
16
+
17
+
18
+ @override_settings(PLUGINS_CONFIG=PLUGIN_CONFIG)
19
+ class QueryViewAccessTest(TestCase):
20
+
21
+ def setUp(self):
22
+ self.superuser = User.objects.create_user(
23
+ "superuser", password="test", is_superuser=True,
24
+ )
25
+ self.regular_user = User.objects.create_user(
26
+ "regular", password="test",
27
+ )
28
+
29
+ def test_unauthenticated_user_gets_403(self):
30
+ response = self.client.get("/plugins/sqlquery/")
31
+ self.assertIn(response.status_code, [302, 403])
32
+
33
+ def test_non_superuser_gets_403_when_require_superuser_is_true(self):
34
+ self.client.force_login(self.regular_user)
35
+ response = self.client.get("/plugins/sqlquery/")
36
+ self.assertIn(response.status_code, [302, 403])
37
+
38
+ @patch("netbox_sqlquery.views.get_schema", return_value={})
39
+ @patch("netbox_sqlquery.views.get_abstract_schema", return_value={})
40
+ def test_superuser_can_access_query_view(self, mock_abs, mock_schema):
41
+ self.client.force_login(self.superuser)
42
+ response = self.client.get("/plugins/sqlquery/")
43
+ self.assertEqual(response.status_code, 200)
44
+
45
+
46
+ @override_settings(PLUGINS_CONFIG=PLUGIN_CONFIG)
47
+ class QueryViewExecutionTest(TestCase):
48
+
49
+ def setUp(self):
50
+ self.superuser = User.objects.create_user(
51
+ "superuser", password="test", is_superuser=True,
52
+ )
53
+ self.client.force_login(self.superuser)
54
+
55
+ @patch("netbox_sqlquery.views.get_schema", return_value={})
56
+ @patch("netbox_sqlquery.views.get_abstract_schema", return_value={})
57
+ def test_insert_statement_allowed_for_superuser(self, mock_abs, mock_schema):
58
+ response = self.client.post(
59
+ "/plugins/sqlquery/", {"sql": "INSERT INTO foo VALUES (1)"}
60
+ )
61
+ # Superuser can submit write queries (needs confirmation)
62
+ self.assertEqual(response.status_code, 200)
63
+
64
+ @patch("netbox_sqlquery.views.get_schema", return_value={})
65
+ @patch("netbox_sqlquery.views.get_abstract_schema", return_value={})
66
+ def test_drop_statement_is_rejected(self, mock_abs, mock_schema):
67
+ response = self.client.post(
68
+ "/plugins/sqlquery/", {"sql": "DROP TABLE foo"}
69
+ )
70
+ self.assertContains(response, "Only SELECT")
71
+
72
+ @patch("netbox_sqlquery.views.get_schema", return_value={})
73
+ @patch("netbox_sqlquery.views.get_abstract_schema", return_value={})
74
+ def test_denied_table_returns_error(self, mock_abs, mock_schema):
75
+ response = self.client.post(
76
+ "/plugins/sqlquery/", {"sql": "SELECT * FROM auth_user"}
77
+ )
78
+ self.assertContains(response, "Access denied")
79
+
80
+ @patch("netbox_sqlquery.views.get_schema", return_value={})
81
+ @patch("netbox_sqlquery.views.get_abstract_schema", return_value={})
82
+ def test_empty_sql_returns_error(self, mock_abs, mock_schema):
83
+ response = self.client.post(
84
+ "/plugins/sqlquery/", {"sql": ""}
85
+ )
86
+ self.assertContains(response, "No SQL provided")
87
+
88
+ @patch("netbox_sqlquery.views.get_schema", return_value={})
89
+ @patch("netbox_sqlquery.views.get_abstract_schema", return_value={})
90
+ def test_select_query_returns_200(self, mock_abs, mock_schema):
91
+ response = self.client.post(
92
+ "/plugins/sqlquery/", {"sql": "SELECT 1"}
93
+ )
94
+ self.assertEqual(response.status_code, 200)
@@ -0,0 +1,22 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ urlpatterns = [
6
+ path("", views.QueryView.as_view(), name="query"),
7
+ path("saved-queries/", views.SavedQueryListView.as_view(), name="savedquery_list"),
8
+ path("saved-queries/add/", views.SavedQueryEditView.as_view(), name="savedquery_add"),
9
+ path("saved-queries/<int:pk>/", views.SavedQueryDetailView.as_view(), name="savedquery"),
10
+ path(
11
+ "saved-queries/<int:pk>/edit/",
12
+ views.SavedQueryEditView.as_view(),
13
+ name="savedquery_edit",
14
+ ),
15
+ path(
16
+ "saved-queries/<int:pk>/delete/",
17
+ views.SavedQueryDeleteView.as_view(),
18
+ name="savedquery_delete",
19
+ ),
20
+ path("ajax/save-query/", views.SavedQueryAjaxSave.as_view(), name="ajax_save_query"),
21
+ path("ajax/list-queries/", views.SavedQueryAjaxList.as_view(), name="ajax_list_queries"),
22
+ ]
@@ -0,0 +1,354 @@
1
+ import json
2
+ import logging
3
+
4
+ from django.contrib.auth.mixins import UserPassesTestMixin
5
+ from django.db import DatabaseError, connection, transaction
6
+ from django.http import JsonResponse
7
+ from django.views import View
8
+ from django.views.generic import TemplateView
9
+ from netbox.plugins import get_plugin_config
10
+ from netbox.views.generic import (
11
+ ObjectDeleteView,
12
+ ObjectEditView,
13
+ ObjectListView,
14
+ ObjectView,
15
+ )
16
+
17
+ from .access import (
18
+ ALL_TABLES,
19
+ _allowed_tables,
20
+ _hard_denies_set,
21
+ can_execute_write,
22
+ check_access,
23
+ extract_tables,
24
+ )
25
+ from .filtersets import SavedQueryFilterSet
26
+ from .forms import SavedQueryFilterForm, SavedQueryForm
27
+ from .models import SavedQuery
28
+ from .schema import get_abstract_schema, get_schema
29
+ from .tables import SavedQueryTable
30
+
31
+ logger = logging.getLogger("netbox_sqlquery")
32
+
33
+
34
+ class QueryView(UserPassesTestMixin, TemplateView):
35
+ template_name = "netbox_sqlquery/query.html"
36
+
37
+ def test_func(self):
38
+ user = self.request.user
39
+ if not user.is_active:
40
+ return False
41
+ if user.is_superuser:
42
+ return True
43
+ if get_plugin_config("netbox_sqlquery", "require_superuser"):
44
+ return False
45
+ return user.has_perm("netbox_sqlquery.view_querypermission")
46
+
47
+ def get_context_data(self, **kwargs):
48
+ ctx = super().get_context_data(**kwargs)
49
+
50
+ # Determine mode from request
51
+ mode = self.request.GET.get("mode") or self.request.POST.get("mode", "")
52
+ if mode not in ("raw", "abstract"):
53
+ mode = self.request.session.get("sqlquery_mode", "raw")
54
+ self.request.session["sqlquery_mode"] = mode
55
+ ctx["mode"] = mode
56
+
57
+ # Raw schema (always needed for raw mode)
58
+ schema = get_schema()
59
+ allowed = _allowed_tables(self.request.user)
60
+ denied = _hard_denies_set()
61
+
62
+ if allowed is ALL_TABLES:
63
+ raw_schema = {
64
+ t: cols for t, cols in schema.items() if t not in denied
65
+ }
66
+ else:
67
+ raw_schema = {
68
+ t: cols for t, cols in schema.items()
69
+ if t in allowed and t not in denied
70
+ }
71
+ ctx["raw_schema"] = raw_schema
72
+
73
+ # Abstract schema
74
+ abstract_schema = get_abstract_schema()
75
+ ctx["abstract_schema"] = abstract_schema
76
+
77
+ # Set active schema based on mode
78
+ if mode == "abstract":
79
+ ctx["schema"] = abstract_schema
80
+ else:
81
+ ctx["schema"] = raw_schema
82
+
83
+ # JSON for the JS editor autocomplete (both modes)
84
+ raw_dict = {t: [c[0] for c in cols] for t, cols in raw_schema.items()}
85
+ abstract_dict = {t: [c[0] for c in cols] for t, cols in abstract_schema.items()}
86
+ ctx["schema_json"] = json.dumps(raw_dict)
87
+ ctx["abstract_schema_json"] = json.dumps(abstract_dict)
88
+
89
+ # Load saved query if ?load=<pk> is in the URL
90
+ load_pk = self.request.GET.get("load")
91
+ if load_pk:
92
+ try:
93
+ sq = SavedQuery.visible_to(self.request.user).get(pk=load_pk)
94
+ ctx["sql"] = sq.sql
95
+ except (SavedQuery.DoesNotExist, ValueError):
96
+ pass
97
+
98
+ # Syntax highlighting preferences
99
+ user_config = self.request.user.config
100
+ hl_defaults = {
101
+ "highlight_enabled": "on",
102
+ "color_keyword": "2196f3",
103
+ "color_function": "9c27b0",
104
+ "color_string": "2f6a31",
105
+ "color_number": "ff5722",
106
+ "color_operator": "aa1409",
107
+ "color_comment": "9e9e9e",
108
+ "skip_write_confirm": "off",
109
+ }
110
+ # Pre-populate defaults so the preferences page shows correct values
111
+ if not user_config.get("plugins.netbox_sqlquery.highlight_enabled"):
112
+ for key, val in hl_defaults.items():
113
+ user_config.set(f"plugins.netbox_sqlquery.{key}", val)
114
+ user_config.save()
115
+
116
+ ctx["highlight_prefs_json"] = json.dumps({
117
+ "enabled": user_config.get(
118
+ "plugins.netbox_sqlquery.highlight_enabled", "on") == "on",
119
+ "keyword": user_config.get(
120
+ "plugins.netbox_sqlquery.color_keyword", "2196f3"),
121
+ "function": user_config.get(
122
+ "plugins.netbox_sqlquery.color_function", "9c27b0"),
123
+ "string": user_config.get(
124
+ "plugins.netbox_sqlquery.color_string", "2f6a31"),
125
+ "number": user_config.get(
126
+ "plugins.netbox_sqlquery.color_number", "ff5722"),
127
+ "operator": user_config.get(
128
+ "plugins.netbox_sqlquery.color_operator", "aa1409"),
129
+ "comment": user_config.get(
130
+ "plugins.netbox_sqlquery.color_comment", "9e9e9e"),
131
+ })
132
+
133
+ # Write query support flags
134
+ ctx["can_write"] = can_execute_write(self.request.user)
135
+ ctx["is_superuser"] = self.request.user.is_superuser
136
+ ctx["skip_write_confirm"] = user_config.get(
137
+ "plugins.netbox_sqlquery.skip_write_confirm", "off") == "on"
138
+
139
+ return ctx
140
+
141
+ def post(self, request):
142
+ sql = request.POST.get("sql", "").strip()
143
+ ctx = self.get_context_data()
144
+ ctx["sql"] = sql
145
+
146
+ if not sql:
147
+ ctx["error"] = "No SQL provided."
148
+ return self.render_to_response(ctx)
149
+
150
+ normalized = sql.lstrip().upper()
151
+ is_select = normalized.startswith("SELECT") or normalized.startswith("WITH")
152
+ is_write = (
153
+ normalized.startswith("INSERT")
154
+ or normalized.startswith("UPDATE")
155
+ or normalized.startswith("DELETE")
156
+ )
157
+
158
+ if not is_select and not is_write:
159
+ ctx["error"] = "Only SELECT, INSERT, UPDATE, and DELETE queries are permitted."
160
+ return self.render_to_response(ctx)
161
+
162
+ if is_write and not can_execute_write(request.user):
163
+ ctx["error"] = (
164
+ "Write queries require the 'change' permission"
165
+ " or superuser status."
166
+ )
167
+ return self.render_to_response(ctx)
168
+
169
+ if is_write and request.POST.get("confirmed") != "1":
170
+ ctx["needs_confirm"] = True
171
+ return self.render_to_response(ctx)
172
+
173
+ denied = check_access(request.user, extract_tables(sql))
174
+ if denied:
175
+ ctx["error"] = f"Access denied to: {', '.join(sorted(denied))}"
176
+ return self.render_to_response(ctx)
177
+
178
+ max_rows = get_plugin_config("netbox_sqlquery", "max_rows")
179
+ timeout_ms = get_plugin_config("netbox_sqlquery", "statement_timeout_ms")
180
+
181
+ if is_write:
182
+ ctx = self._execute_write(ctx, sql, timeout_ms)
183
+ else:
184
+ ctx = self._execute_read(ctx, sql, timeout_ms, max_rows)
185
+
186
+ _record_query(request.user, sql)
187
+ return self.render_to_response(ctx)
188
+
189
+ def _execute_read(self, ctx, sql, timeout_ms, max_rows):
190
+ try:
191
+ with transaction.atomic():
192
+ with connection.cursor() as cursor:
193
+ cursor.execute(f"SET LOCAL statement_timeout = '{timeout_ms}'")
194
+ cursor.execute("SET TRANSACTION READ ONLY")
195
+ cursor.execute(sql)
196
+ columns = [col[0] for col in cursor.description]
197
+ rows = cursor.fetchmany(max_rows)
198
+ raise _ReadOnlyRollback()
199
+ except _ReadOnlyRollback:
200
+ ctx.update(columns=columns, rows=rows, row_count=len(rows))
201
+ except DatabaseError as exc:
202
+ ctx["error"] = str(exc)
203
+ return ctx
204
+
205
+ def _execute_write(self, ctx, sql, timeout_ms):
206
+ max_rows = get_plugin_config("netbox_sqlquery", "max_rows")
207
+ try:
208
+ # Append RETURNING * if the user didn't include a RETURNING clause
209
+ exec_sql = sql
210
+ has_returning = "RETURNING" in sql.upper()
211
+ normalized = sql.lstrip().upper()
212
+ if not has_returning and (
213
+ normalized.startswith("UPDATE") or normalized.startswith("DELETE")
214
+ ):
215
+ exec_sql = sql.rstrip().rstrip(";") + " RETURNING *"
216
+
217
+ with connection.cursor() as cursor:
218
+ cursor.execute(f"SET LOCAL statement_timeout = '{timeout_ms}'")
219
+ cursor.execute(exec_sql)
220
+ row_count = cursor.rowcount
221
+
222
+ if cursor.description:
223
+ columns = [col[0] for col in cursor.description]
224
+ rows = cursor.fetchmany(max_rows)
225
+ ctx.update(columns=columns, rows=rows, row_count=len(rows))
226
+
227
+ ctx["write_result"] = f"{row_count} row{'s' if row_count != 1 else ''} affected."
228
+ except DatabaseError as exc:
229
+ ctx["error"] = str(exc)
230
+ return ctx
231
+
232
+
233
+ class _ReadOnlyRollback(Exception):
234
+ """Raised to force rollback of the read-only transaction."""
235
+
236
+
237
+ def _record_query(user, sql):
238
+ truncated = sql[:500] if len(sql) > 500 else sql
239
+ logger.info("query user=%s sql=%s", user.username, truncated)
240
+
241
+
242
+ class _SavedQueryPermMixin:
243
+ """Use the plugin's view_querypermission instead of model-derived permissions."""
244
+
245
+ def get_required_permission(self):
246
+ return "netbox_sqlquery.view_querypermission"
247
+
248
+
249
+ class SavedQueryListView(_SavedQueryPermMixin, ObjectListView):
250
+ queryset = SavedQuery.objects.all()
251
+ table = SavedQueryTable
252
+ filterset = SavedQueryFilterSet
253
+ filterset_form = SavedQueryFilterForm
254
+
255
+ def get_queryset(self, request):
256
+ return SavedQuery.visible_to(request.user)
257
+
258
+
259
+ class SavedQueryDetailView(_SavedQueryPermMixin, ObjectView):
260
+ queryset = SavedQuery.objects.all()
261
+ template_name = "netbox_sqlquery/saved_query.html"
262
+
263
+
264
+ class SavedQueryEditView(_SavedQueryPermMixin, ObjectEditView):
265
+ queryset = SavedQuery.objects.all()
266
+ form = SavedQueryForm
267
+
268
+ def get_required_permission(self):
269
+ return "netbox_sqlquery.view_querypermission"
270
+
271
+ def alter_object(self, obj, request, url_args, url_kwargs):
272
+ if not obj.pk:
273
+ obj.owner = request.user
274
+ return obj
275
+
276
+
277
+ class SavedQueryDeleteView(_SavedQueryPermMixin, ObjectDeleteView):
278
+ queryset = SavedQuery.objects.all()
279
+
280
+
281
+ class SavedQueryAjaxSave(UserPassesTestMixin, View):
282
+ """AJAX endpoint to save a query from the editor."""
283
+
284
+ def test_func(self):
285
+ return self.request.user.is_active and self.request.user.is_authenticated
286
+
287
+ def post(self, request):
288
+ try:
289
+ data = json.loads(request.body)
290
+ except json.JSONDecodeError:
291
+ return JsonResponse({"error": "Invalid JSON"}, status=400)
292
+
293
+ name = (data.get("name") or "").strip()
294
+ sql = (data.get("sql") or "").strip()
295
+ visibility = data.get("visibility", SavedQuery.VISIBILITY_PRIVATE)
296
+ description = (data.get("description") or "").strip()
297
+
298
+ if not name or not sql:
299
+ return JsonResponse({"error": "Name and SQL are required."}, status=400)
300
+
301
+ if len(name) > 100:
302
+ return JsonResponse({"error": "Name must be 100 characters or fewer."}, status=400)
303
+
304
+ # Validate name against injection
305
+ import re
306
+ if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9 _\-\.]*$', name):
307
+ return JsonResponse({
308
+ "error": "Name must start with a letter or number and contain only "
309
+ "letters, numbers, spaces, hyphens, underscores, and periods."
310
+ }, status=400)
311
+
312
+ if visibility not in dict(SavedQuery.VISIBILITY_CHOICES):
313
+ return JsonResponse({"error": "Invalid visibility."}, status=400)
314
+
315
+ query = SavedQuery.objects.create(
316
+ name=name,
317
+ sql=sql,
318
+ description=description,
319
+ visibility=visibility,
320
+ owner=request.user,
321
+ )
322
+ return JsonResponse({
323
+ "id": query.pk,
324
+ "name": query.name,
325
+ "message": f"Query '{query.name}' saved.",
326
+ })
327
+
328
+
329
+ class SavedQueryAjaxList(UserPassesTestMixin, View):
330
+ """AJAX endpoint to list saved queries for the load dialog."""
331
+
332
+ def test_func(self):
333
+ return self.request.user.is_active and self.request.user.is_authenticated
334
+
335
+ def get(self, request):
336
+ search = request.GET.get("q", "").strip()
337
+ queries = SavedQuery.visible_to(request.user)
338
+ if search:
339
+ queries = queries.filter(name__icontains=search)
340
+ queries = queries.order_by("name")[:50]
341
+ return JsonResponse({
342
+ "results": [
343
+ {
344
+ "id": q.pk,
345
+ "name": q.name,
346
+ "description": q.description,
347
+ "sql": q.sql,
348
+ "visibility": q.get_visibility_display(),
349
+ "owner": q.owner.username,
350
+ "is_own": q.owner_id == request.user.pk,
351
+ }
352
+ for q in queries
353
+ ]
354
+ })
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: netbox-sqlquery
3
+ Version: 0.1.0
4
+ Summary: SQL query interface for NetBox with syntax highlighting, abstract views, and role-based access control
5
+ Author-email: Ravi Pina <ravi@pina.org>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/ravinald/netbox-sqlquery
8
+ Project-URL: Issues, https://github.com/ravinald/netbox-sqlquery/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # netbox-sqlquery
15
+
16
+ [![CI](https://github.com/ravinald/netbox-sqlquery/actions/workflows/ci.yml/badge.svg)](https://github.com/ravinald/netbox-sqlquery/actions/workflows/ci.yml)
17
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
18
+
19
+ A NetBox plugin that provides a SQL query interface with syntax highlighting, abstract views, saved queries, and role-based access control.
20
+
21
+ ## Features
22
+
23
+ - SQL console with real-time syntax highlighting and auto-uppercase keywords
24
+ - Abstract views (`nb_*`) that resolve foreign keys to names and aggregate tags
25
+ - Schema sidebar with search filter and Raw SQL / Views toggle
26
+ - Interactive results: click columns to refine SELECT, click cells to add WHERE filters
27
+ - Saved queries with save/load dialogs and private/public visibility
28
+ - Write query support (INSERT, UPDATE, DELETE) with confirmation dialog
29
+ - Role-based access control integrated with NetBox's ObjectPermission system
30
+ - Per-user color preferences for syntax highlighting
31
+ - CSV export
32
+ - Compatible with NetBox 4.0 through 4.5, netbox-docker, and OIDC providers
33
+
34
+ ## Screenshots
35
+
36
+ ### Views mode
37
+ Query abstract views that resolve foreign keys and aggregate tags, matching how data appears in the NetBox UI.
38
+
39
+ <img src="docs/images/view-query.png" alt="Views mode query" width="800">
40
+
41
+ ### Raw SQL mode
42
+ Query the database directly with full PostgreSQL power.
43
+
44
+ <img src="docs/images/raw-query.png" alt="Raw SQL query" width="800">
45
+
46
+ ### Column select
47
+ Click column headers to refine which columns are returned.
48
+
49
+ <img src="docs/images/view-query-column-select.png" alt="Column selection" width="800">
50
+
51
+ ### Saving queries
52
+ Save queries for reuse with private or public visibility.
53
+
54
+ <img src="docs/images/saving-query.png" alt="Save query dialog" width="400">
55
+
56
+ ### Saved queries list
57
+
58
+ <img src="docs/images/saved-query-list.png" alt="Saved queries list" width="800">
59
+
60
+ ### Permissions setup
61
+ Control access using NetBox's native ObjectPermission system.
62
+
63
+ <img src="docs/images/permissions.png" alt="Permissions setup" width="800">
64
+
65
+ ### User preferences
66
+ Customize syntax highlighting colors per user.
67
+
68
+ <img src="docs/images/user-preferences.png" alt="User preferences" width="800">
69
+
70
+ ## Compatibility
71
+
72
+ See [COMPATIBILITY.md](COMPATIBILITY.md) for the full version matrix.
73
+
74
+ | NetBox version | Python versions |
75
+ |----------------|------------------------------|
76
+ | 4.0 - 4.5 | 3.10, 3.11, 3.12, 3.13, 3.14 |
77
+
78
+ ## Installation
79
+
80
+ ```bash
81
+ pip install netbox-sqlquery
82
+ ```
83
+
84
+ Add to your NetBox `configuration.py`:
85
+
86
+ ```python
87
+ PLUGINS = ["netbox_sqlquery"]
88
+
89
+ PLUGINS_CONFIG = {
90
+ "netbox_sqlquery": {
91
+ "require_superuser": True,
92
+ "max_rows": 1000,
93
+ "statement_timeout_ms": 10000,
94
+ "deny_tables": [
95
+ "auth_user",
96
+ "users_token",
97
+ "users_userconfig",
98
+ ],
99
+ }
100
+ }
101
+ ```
102
+
103
+ Run migrations:
104
+
105
+ ```bash
106
+ python manage.py migrate netbox_sqlquery
107
+ ```
108
+
109
+ Collect static files:
110
+
111
+ ```bash
112
+ python manage.py collectstatic --no-input
113
+ ```
114
+
115
+ For Docker-based installations, see [docs/docker.md](docs/docker.md).
116
+
117
+ ## Configuration
118
+
119
+ | Setting | Type | Default | Description |
120
+ |------------------------|------|----------------------------------------------------|-------------------------------------------------------|
121
+ | `require_superuser` | bool | `True` | Require superuser to access the query view |
122
+ | `max_rows` | int | `1000` | Maximum rows returned per query |
123
+ | `statement_timeout_ms` | int | `10000` | PostgreSQL statement timeout in milliseconds |
124
+ | `deny_tables` | list | `["auth_user", "users_token", "users_userconfig"]` | Tables blocked for all users including superusers |
125
+ | `top_level_menu` | bool | `False` | Show as a top-level nav menu instead of under Plugins |
126
+
127
+ ## Documentation
128
+
129
+ - [Installation](docs/installation.md)
130
+ - [Configuration](docs/configuration.md)
131
+ - [Usage](docs/usage.md)
132
+ - [Permissions](docs/permissions.md)
133
+ - [Saved queries](docs/saved-queries.md)
134
+ - [Operations](docs/operations.md)
135
+ - [Docker](docs/docker.md)
136
+ - [Uninstalling](docs/uninstall.md)
137
+
138
+ ## License
139
+
140
+ Apache 2.0. See [LICENSE](LICENSE).