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.
- netbox_sqlquery/__init__.py +72 -0
- netbox_sqlquery/abstract_schema.py +406 -0
- netbox_sqlquery/access.py +157 -0
- netbox_sqlquery/api/__init__.py +0 -0
- netbox_sqlquery/api/serializers.py +35 -0
- netbox_sqlquery/api/urls.py +8 -0
- netbox_sqlquery/api/views.py +15 -0
- netbox_sqlquery/filtersets.py +12 -0
- netbox_sqlquery/forms.py +20 -0
- netbox_sqlquery/management/__init__.py +0 -0
- netbox_sqlquery/management/commands/__init__.py +0 -0
- netbox_sqlquery/management/commands/sqlquery_create_views.py +39 -0
- netbox_sqlquery/migrations/0001_initial.py +95 -0
- netbox_sqlquery/migrations/0002_query_permissions.py +21 -0
- netbox_sqlquery/migrations/0003_tablepermission_groups_to_users_group.py +17 -0
- netbox_sqlquery/migrations/__init__.py +0 -0
- netbox_sqlquery/models.py +106 -0
- netbox_sqlquery/navigation.py +44 -0
- netbox_sqlquery/preferences.py +64 -0
- netbox_sqlquery/schema.py +52 -0
- netbox_sqlquery/tables.py +18 -0
- netbox_sqlquery/tests/__init__.py +0 -0
- netbox_sqlquery/tests/test_access.py +96 -0
- netbox_sqlquery/tests/test_api.py +41 -0
- netbox_sqlquery/tests/test_models.py +45 -0
- netbox_sqlquery/tests/test_views.py +94 -0
- netbox_sqlquery/urls.py +22 -0
- netbox_sqlquery/views.py +354 -0
- netbox_sqlquery-0.1.0.dist-info/METADATA +140 -0
- netbox_sqlquery-0.1.0.dist-info/RECORD +33 -0
- netbox_sqlquery-0.1.0.dist-info/WHEEL +5 -0
- netbox_sqlquery-0.1.0.dist-info/licenses/LICENSE +200 -0
- netbox_sqlquery-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
netbox_sqlquery/urls.py
ADDED
|
@@ -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
|
+
]
|
netbox_sqlquery/views.py
ADDED
|
@@ -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
|
+
[](https://github.com/ravinald/netbox-sqlquery/actions/workflows/ci.yml)
|
|
17
|
+
[](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).
|