netbox-sqlquery 0.1.3__tar.gz → 0.1.4__tar.gz

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.
Files changed (43) hide show
  1. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/PKG-INFO +1 -1
  2. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/__init__.py +1 -1
  3. netbox_sqlquery-0.1.4/netbox_sqlquery/api/views.py +107 -0
  4. netbox_sqlquery-0.1.4/netbox_sqlquery/query.py +115 -0
  5. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/views.py +22 -43
  6. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/PKG-INFO +1 -1
  7. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/SOURCES.txt +1 -0
  8. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/pyproject.toml +1 -1
  9. netbox_sqlquery-0.1.3/netbox_sqlquery/api/views.py +0 -15
  10. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/LICENSE +0 -0
  11. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/README.md +0 -0
  12. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/abstract_schema.py +0 -0
  13. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/access.py +0 -0
  14. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/api/__init__.py +0 -0
  15. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/api/serializers.py +0 -0
  16. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/api/urls.py +0 -0
  17. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/filtersets.py +0 -0
  18. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/forms.py +0 -0
  19. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/__init__.py +0 -0
  20. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/commands/__init__.py +0 -0
  21. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/commands/sqlquery_create_views.py +0 -0
  22. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/0001_initial.py +0 -0
  23. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/0002_query_permissions.py +0 -0
  24. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/__init__.py +0 -0
  25. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/models.py +0 -0
  26. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/navigation.py +0 -0
  27. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/preferences.py +0 -0
  28. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/schema.py +0 -0
  29. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/editor.js +0 -0
  30. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/icon.LICENSE +0 -0
  31. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/icon.svg +0 -0
  32. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tables.py +0 -0
  33. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/templates/netbox_sqlquery/query.html +0 -0
  34. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/templates/netbox_sqlquery/saved_query.html +0 -0
  35. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/__init__.py +0 -0
  36. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_access.py +0 -0
  37. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_api.py +0 -0
  38. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_models.py +0 -0
  39. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_views.py +0 -0
  40. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery/urls.py +0 -0
  41. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/dependency_links.txt +0 -0
  42. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/top_level.txt +0 -0
  43. {netbox_sqlquery-0.1.3 → netbox_sqlquery-0.1.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-sqlquery
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: SQL query interface for NetBox with syntax highlighting, abstract views, and role-based access control
5
5
  Author-email: Ravi Pina <ravi@pina.org>
6
6
  License-Expression: Apache-2.0
@@ -12,7 +12,7 @@ class NetBoxSQLQueryConfig(PluginConfig):
12
12
  "SQL query interface for NetBox with syntax highlighting,"
13
13
  " abstract views, and role-based access control"
14
14
  )
15
- version = "0.1.3"
15
+ version = "0.1.4"
16
16
  author = "Ravi Pina"
17
17
  author_email = "ravi@pina.org"
18
18
  base_url = "sqlquery"
@@ -0,0 +1,107 @@
1
+ import logging
2
+
3
+ from django.utils import timezone
4
+ from rest_framework import status
5
+ from rest_framework.decorators import action
6
+ from rest_framework.response import Response
7
+ from rest_framework.viewsets import ModelViewSet
8
+
9
+ from netbox_sqlquery.access import can_execute_write, check_access, extract_tables
10
+ from netbox_sqlquery.models import SavedQuery
11
+ from netbox_sqlquery.query import execute_read_query, execute_write_query, is_write_query
12
+
13
+ from .serializers import SavedQuerySerializer
14
+
15
+ logger = logging.getLogger("netbox_sqlquery")
16
+
17
+
18
+ class SavedQueryViewSet(ModelViewSet):
19
+ serializer_class = SavedQuerySerializer
20
+
21
+ def get_queryset(self):
22
+ return SavedQuery.visible_to(self.request.user)
23
+
24
+ def perform_create(self, serializer):
25
+ serializer.save(owner=self.request.user)
26
+
27
+ @action(detail=True, methods=["post"])
28
+ def execute(self, request, pk=None):
29
+ """Execute a saved query and return results as JSON."""
30
+ saved_query = self.get_object()
31
+ sql = saved_query.sql.strip()
32
+ user = request.user
33
+
34
+ if not sql:
35
+ return Response(
36
+ {"error": "Saved query has no SQL."},
37
+ status=status.HTTP_400_BAD_REQUEST,
38
+ )
39
+
40
+ is_write = is_write_query(sql)
41
+
42
+ # Check write permission
43
+ if is_write and not can_execute_write(user):
44
+ return Response(
45
+ {"error": "Write queries require the 'change' permission or superuser status."},
46
+ status=status.HTTP_403_FORBIDDEN,
47
+ )
48
+
49
+ # Require confirmation for write queries
50
+ if is_write:
51
+ confirmed = request.data.get("confirmed")
52
+ if not confirmed:
53
+ return Response(
54
+ {
55
+ "error": "Write queries require explicit confirmation.",
56
+ "detail": 'Include {"confirmed": true} in the request body.',
57
+ },
58
+ status=status.HTTP_400_BAD_REQUEST,
59
+ )
60
+
61
+ # Table access check
62
+ denied = check_access(user, extract_tables(sql))
63
+ if denied:
64
+ return Response(
65
+ {"error": f"Access denied to: {', '.join(sorted(denied))}"},
66
+ status=status.HTTP_403_FORBIDDEN,
67
+ )
68
+
69
+ # Execute
70
+ if is_write:
71
+ result = execute_write_query(sql)
72
+ else:
73
+ result = execute_read_query(sql)
74
+
75
+ if result.get("error"):
76
+ return Response(
77
+ {"error": result["error"]},
78
+ status=status.HTTP_400_BAD_REQUEST,
79
+ )
80
+
81
+ # Update run tracking
82
+ saved_query.run_count += 1
83
+ saved_query.last_run = timezone.now()
84
+ saved_query.save(update_fields=["run_count", "last_run"])
85
+
86
+ # Audit log
87
+ truncated_sql = sql[:500]
88
+ logger.info(
89
+ "api query user=%s query=%s sql=%s",
90
+ user.username,
91
+ saved_query.name,
92
+ truncated_sql,
93
+ )
94
+
95
+ # Build response
96
+ response_data = {
97
+ "query_name": saved_query.name,
98
+ "columns": result["columns"],
99
+ "rows": result["rows"],
100
+ "row_count": result["row_count"],
101
+ }
102
+ if is_write:
103
+ response_data["rows_affected"] = result.get("rows_affected", 0)
104
+ else:
105
+ response_data["truncated"] = result.get("truncated", False)
106
+
107
+ return Response(response_data)
@@ -0,0 +1,115 @@
1
+ """Shared query execution functions used by both the web UI and API."""
2
+
3
+ from django.db import DatabaseError, connection, transaction
4
+ from netbox.plugins import get_plugin_config
5
+
6
+
7
+ class _ReadOnlyRollback(Exception):
8
+ """Raised to force rollback of the read-only transaction."""
9
+
10
+
11
+ def execute_read_query(sql, timeout_ms=None, max_rows=None):
12
+ """Execute a read-only SQL query.
13
+
14
+ Returns dict with keys: columns, rows, row_count, truncated, error.
15
+ """
16
+ if timeout_ms is None:
17
+ timeout_ms = get_plugin_config("netbox_sqlquery", "statement_timeout_ms")
18
+ if max_rows is None:
19
+ max_rows = get_plugin_config("netbox_sqlquery", "max_rows")
20
+
21
+ result = {"columns": [], "rows": [], "row_count": 0, "truncated": False, "error": None}
22
+
23
+ try:
24
+ with transaction.atomic():
25
+ with connection.cursor() as cursor:
26
+ cursor.execute(f"SET LOCAL statement_timeout = '{timeout_ms}'")
27
+ cursor.execute("SET TRANSACTION READ ONLY")
28
+ cursor.execute(sql)
29
+ columns = [col[0] for col in cursor.description]
30
+ rows = cursor.fetchmany(max_rows + 1)
31
+ raise _ReadOnlyRollback()
32
+ except _ReadOnlyRollback:
33
+ truncated = len(rows) > max_rows
34
+ if truncated:
35
+ rows = rows[:max_rows]
36
+ result.update(
37
+ columns=columns,
38
+ rows=[list(r) for r in rows],
39
+ row_count=len(rows),
40
+ truncated=truncated,
41
+ )
42
+ except DatabaseError as exc:
43
+ result["error"] = str(exc)
44
+
45
+ return result
46
+
47
+
48
+ def execute_write_query(sql, timeout_ms=None, max_rows=None):
49
+ """Execute a write SQL query (INSERT/UPDATE/DELETE).
50
+
51
+ Returns dict with keys: columns, rows, row_count, rows_affected, error.
52
+ """
53
+ if timeout_ms is None:
54
+ timeout_ms = get_plugin_config("netbox_sqlquery", "statement_timeout_ms")
55
+ if max_rows is None:
56
+ max_rows = get_plugin_config("netbox_sqlquery", "max_rows")
57
+
58
+ result = {
59
+ "columns": [],
60
+ "rows": [],
61
+ "row_count": 0,
62
+ "rows_affected": 0,
63
+ "error": None,
64
+ }
65
+
66
+ try:
67
+ exec_sql = sql
68
+ has_returning = "RETURNING" in sql.upper()
69
+ normalized = sql.lstrip().upper()
70
+ if not has_returning and (
71
+ normalized.startswith("UPDATE") or normalized.startswith("DELETE")
72
+ ):
73
+ exec_sql = sql.rstrip().rstrip(";") + " RETURNING *"
74
+
75
+ with connection.cursor() as cursor:
76
+ cursor.execute(f"SET LOCAL statement_timeout = '{timeout_ms}'")
77
+ cursor.execute(exec_sql)
78
+ rows_affected = cursor.rowcount
79
+
80
+ if cursor.description:
81
+ columns = [col[0] for col in cursor.description]
82
+ rows = cursor.fetchmany(max_rows)
83
+ result.update(
84
+ columns=columns,
85
+ rows=[list(r) for r in rows],
86
+ row_count=len(rows),
87
+ )
88
+
89
+ result["rows_affected"] = rows_affected
90
+ except DatabaseError as exc:
91
+ result["error"] = str(exc)
92
+
93
+ return result
94
+
95
+
96
+ def is_write_query(sql):
97
+ """Check if a SQL statement is a write query."""
98
+ normalized = sql.lstrip().upper()
99
+ return (
100
+ normalized.startswith("INSERT")
101
+ or normalized.startswith("UPDATE")
102
+ or normalized.startswith("DELETE")
103
+ )
104
+
105
+
106
+ def is_allowed_query(sql):
107
+ """Check if a SQL statement type is permitted."""
108
+ normalized = sql.lstrip().upper()
109
+ return (
110
+ normalized.startswith("SELECT")
111
+ or normalized.startswith("WITH")
112
+ or normalized.startswith("INSERT")
113
+ or normalized.startswith("UPDATE")
114
+ or normalized.startswith("DELETE")
115
+ )
@@ -2,6 +2,7 @@ import csv
2
2
  import io
3
3
  import json
4
4
  import logging
5
+ import re
5
6
 
6
7
  from django.contrib.auth.mixins import UserPassesTestMixin
7
8
  from django.db import DatabaseError, connection, transaction
@@ -27,6 +28,7 @@ from .access import (
27
28
  from .filtersets import SavedQueryFilterSet
28
29
  from .forms import SavedQueryFilterForm, SavedQueryForm
29
30
  from .models import SavedQuery
31
+ from .query import execute_read_query, execute_write_query
30
32
  from .schema import get_abstract_schema, get_schema
31
33
  from .tables import SavedQueryTable
32
34
 
@@ -180,55 +182,33 @@ class QueryView(UserPassesTestMixin, TemplateView):
180
182
  return self.render_to_response(ctx)
181
183
 
182
184
  def _execute_read(self, ctx, sql, timeout_ms, max_rows):
183
- try:
184
- with transaction.atomic():
185
- with connection.cursor() as cursor:
186
- cursor.execute(f"SET LOCAL statement_timeout = '{timeout_ms}'")
187
- cursor.execute("SET TRANSACTION READ ONLY")
188
- cursor.execute(sql)
189
- columns = [col[0] for col in cursor.description]
190
- rows = cursor.fetchmany(max_rows + 1)
191
- raise _ReadOnlyRollback()
192
- except _ReadOnlyRollback:
193
- truncated = len(rows) > max_rows
194
- if truncated:
195
- rows = rows[:max_rows]
185
+ result = execute_read_query(sql, timeout_ms, max_rows)
186
+ if result["error"]:
187
+ ctx["error"] = result["error"]
188
+ else:
196
189
  ctx.update(
197
- columns=columns,
198
- rows=rows,
199
- row_count=len(rows),
200
- truncated=truncated,
190
+ columns=result["columns"],
191
+ rows=result["rows"],
192
+ row_count=result["row_count"],
193
+ truncated=result["truncated"],
201
194
  max_rows=max_rows,
202
195
  )
203
- except DatabaseError as exc:
204
- ctx["error"] = str(exc)
205
196
  return ctx
206
197
 
207
198
  def _execute_write(self, ctx, sql, timeout_ms):
208
199
  max_rows = get_plugin_config("netbox_sqlquery", "max_rows")
209
- try:
210
- # Append RETURNING * if the user didn't include a RETURNING clause
211
- exec_sql = sql
212
- has_returning = "RETURNING" in sql.upper()
213
- normalized = sql.lstrip().upper()
214
- if not has_returning and (
215
- normalized.startswith("UPDATE") or normalized.startswith("DELETE")
216
- ):
217
- exec_sql = sql.rstrip().rstrip(";") + " RETURNING *"
218
-
219
- with connection.cursor() as cursor:
220
- cursor.execute(f"SET LOCAL statement_timeout = '{timeout_ms}'")
221
- cursor.execute(exec_sql)
222
- row_count = cursor.rowcount
223
-
224
- if cursor.description:
225
- columns = [col[0] for col in cursor.description]
226
- rows = cursor.fetchmany(max_rows)
227
- ctx.update(columns=columns, rows=rows, row_count=len(rows))
228
-
229
- ctx["write_result"] = f"{row_count} row{'s' if row_count != 1 else ''} affected."
230
- except DatabaseError as exc:
231
- ctx["error"] = str(exc)
200
+ result = execute_write_query(sql, timeout_ms, max_rows)
201
+ if result["error"]:
202
+ ctx["error"] = result["error"]
203
+ else:
204
+ if result["columns"]:
205
+ ctx.update(
206
+ columns=result["columns"],
207
+ rows=result["rows"],
208
+ row_count=result["row_count"],
209
+ )
210
+ affected = result["rows_affected"]
211
+ ctx["write_result"] = f"{affected} row{'s' if affected != 1 else ''} affected."
232
212
  return ctx
233
213
 
234
214
 
@@ -304,7 +284,6 @@ class SavedQueryAjaxSave(UserPassesTestMixin, View):
304
284
  return JsonResponse({"error": "Name must be 100 characters or fewer."}, status=400)
305
285
 
306
286
  # Validate name against injection
307
- import re
308
287
 
309
288
  if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9 _\-\.]*$", name):
310
289
  return JsonResponse(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-sqlquery
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: SQL query interface for NetBox with syntax highlighting, abstract views, and role-based access control
5
5
  Author-email: Ravi Pina <ravi@pina.org>
6
6
  License-Expression: Apache-2.0
@@ -9,6 +9,7 @@ netbox_sqlquery/forms.py
9
9
  netbox_sqlquery/models.py
10
10
  netbox_sqlquery/navigation.py
11
11
  netbox_sqlquery/preferences.py
12
+ netbox_sqlquery/query.py
12
13
  netbox_sqlquery/schema.py
13
14
  netbox_sqlquery/tables.py
14
15
  netbox_sqlquery/urls.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "netbox-sqlquery"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "SQL query interface for NetBox with syntax highlighting, abstract views, and role-based access control"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,15 +0,0 @@
1
- from rest_framework.viewsets import ModelViewSet
2
-
3
- from netbox_sqlquery.models import SavedQuery
4
-
5
- from .serializers import SavedQuerySerializer
6
-
7
-
8
- class SavedQueryViewSet(ModelViewSet):
9
- serializer_class = SavedQuerySerializer
10
-
11
- def get_queryset(self):
12
- return SavedQuery.visible_to(self.request.user)
13
-
14
- def perform_create(self, serializer):
15
- serializer.save(owner=self.request.user)
File without changes