netbox-sqlquery 0.1.2__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 (44) hide show
  1. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/PKG-INFO +3 -3
  2. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/README.md +1 -1
  3. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/__init__.py +2 -2
  4. netbox_sqlquery-0.1.4/netbox_sqlquery/api/views.py +107 -0
  5. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/0001_initial.py +2 -2
  6. netbox_sqlquery-0.1.4/netbox_sqlquery/query.py +115 -0
  7. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/editor.js +1 -27
  8. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/templates/netbox_sqlquery/query.html +8 -4
  9. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/urls.py +1 -0
  10. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/views.py +82 -38
  11. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/PKG-INFO +3 -3
  12. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/SOURCES.txt +1 -1
  13. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/pyproject.toml +2 -2
  14. netbox_sqlquery-0.1.2/netbox_sqlquery/api/views.py +0 -15
  15. netbox_sqlquery-0.1.2/netbox_sqlquery/migrations/0003_tablepermission_groups_to_users_group.py +0 -16
  16. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/LICENSE +0 -0
  17. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/abstract_schema.py +0 -0
  18. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/access.py +0 -0
  19. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/api/__init__.py +0 -0
  20. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/api/serializers.py +0 -0
  21. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/api/urls.py +0 -0
  22. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/filtersets.py +0 -0
  23. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/forms.py +0 -0
  24. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/__init__.py +0 -0
  25. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/commands/__init__.py +0 -0
  26. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/commands/sqlquery_create_views.py +0 -0
  27. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/0002_query_permissions.py +0 -0
  28. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/__init__.py +0 -0
  29. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/models.py +0 -0
  30. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/navigation.py +0 -0
  31. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/preferences.py +0 -0
  32. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/schema.py +0 -0
  33. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/icon.LICENSE +0 -0
  34. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/icon.svg +0 -0
  35. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tables.py +0 -0
  36. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/templates/netbox_sqlquery/saved_query.html +0 -0
  37. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/__init__.py +0 -0
  38. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_access.py +0 -0
  39. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_api.py +0 -0
  40. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_models.py +0 -0
  41. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_views.py +0 -0
  42. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/dependency_links.txt +0 -0
  43. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/top_level.txt +0 -0
  44. {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/setup.cfg +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-sqlquery
3
- Version: 0.1.2
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
7
7
  Project-URL: Homepage, https://github.com/ravinald/netbox-sqlquery
8
8
  Project-URL: Issues, https://github.com/ravinald/netbox-sqlquery/issues
9
- Requires-Python: >=3.10
9
+ Requires-Python: >=3.12
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Dynamic: license-file
@@ -73,7 +73,7 @@ See [COMPATIBILITY.md](COMPATIBILITY.md) for the full version matrix.
73
73
 
74
74
  | NetBox version | Python versions |
75
75
  |----------------|------------------------------|
76
- | 4.0 - 4.5 | 3.10, 3.11, 3.12, 3.13, 3.14 |
76
+ | 4.5+ | 3.12, 3.13, 3.14 |
77
77
 
78
78
  ## Installation
79
79
 
@@ -60,7 +60,7 @@ See [COMPATIBILITY.md](COMPATIBILITY.md) for the full version matrix.
60
60
 
61
61
  | NetBox version | Python versions |
62
62
  |----------------|------------------------------|
63
- | 4.0 - 4.5 | 3.10, 3.11, 3.12, 3.13, 3.14 |
63
+ | 4.5+ | 3.12, 3.13, 3.14 |
64
64
 
65
65
  ## Installation
66
66
 
@@ -12,11 +12,11 @@ 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.2"
15
+ version = "0.1.4"
16
16
  author = "Ravi Pina"
17
17
  author_email = "ravi@pina.org"
18
18
  base_url = "sqlquery"
19
- min_version = "4.0.0"
19
+ min_version = "4.5.0"
20
20
  max_version = None
21
21
  required_settings = []
22
22
  default_settings = {
@@ -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)
@@ -8,8 +8,8 @@ class Migration(migrations.Migration):
8
8
 
9
9
  dependencies = [
10
10
  migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11
- ("auth", "0012_alter_user_first_name_max_length"),
12
11
  ("extras", "0001_initial"),
12
+ ("users", "0001_squashed_0011"),
13
13
  ]
14
14
 
15
15
  operations = [
@@ -85,7 +85,7 @@ class Migration(migrations.Migration):
85
85
  ("require_staff", models.BooleanField(default=False)),
86
86
  ("require_superuser", models.BooleanField(default=False)),
87
87
  ("allow", models.BooleanField(default=True)),
88
- ("groups", models.ManyToManyField(blank=True, to="auth.group")),
88
+ ("groups", models.ManyToManyField(blank=True, to="users.group")),
89
89
  ],
90
90
  options={
91
91
  "ordering": ["-require_superuser", "-require_staff", "pattern"],
@@ -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
+ )
@@ -552,33 +552,7 @@
552
552
  });
553
553
 
554
554
 
555
- // CSV download
556
- var csvBtn = document.getElementById("csv-download");
557
- if (csvBtn) {
558
- csvBtn.addEventListener("click", function () {
559
- var table = document.querySelector(".results-pane table");
560
- if (!table) return;
561
- var rows = [];
562
- table.querySelectorAll("tr").forEach(function (tr) {
563
- var cells = [];
564
- tr.querySelectorAll("th, td").forEach(function (cell) {
565
- var text = cell.textContent;
566
- if (text.includes(",") || text.includes('"') || text.includes("\n")) {
567
- text = '"' + text.replace(/"/g, '""') + '"';
568
- }
569
- cells.push(text);
570
- });
571
- rows.push(cells.join(","));
572
- });
573
- var blob = new Blob([rows.join("\n")], { type: "text/csv" });
574
- var url = URL.createObjectURL(blob);
575
- var a = document.createElement("a");
576
- a.href = url;
577
- a.download = "query_results.csv";
578
- a.click();
579
- URL.revokeObjectURL(url);
580
- });
581
- }
555
+ // CSV download is now a server-side form POST (no JS needed)
582
556
 
583
557
  function insertAtCursor(text) {
584
558
  var start = editor.selectionStart;
@@ -299,10 +299,14 @@
299
299
  </tbody>
300
300
  </table>
301
301
  <div class="results-meta">
302
- <span>{{ row_count }} row{{ row_count|pluralize }}</span>
303
- <button type="button" class="btn btn-sm btn-outline-secondary" id="csv-download">
304
- Download CSV
305
- </button>
302
+ <span>{{ row_count }} row{{ row_count|pluralize }}{% if truncated %} <span class="text-warning">(limited to {{ max_rows }} -- add LIMIT/OFFSET to page through results)</span>{% endif %}</span>
303
+ <form method="post" action="{% url 'plugins:netbox_sqlquery:export_csv' %}" style="display:inline;">
304
+ {% csrf_token %}
305
+ <input type="hidden" name="sql" value="{{ sql }}">
306
+ <button type="submit" class="btn btn-sm btn-outline-secondary" title="Download all results as CSV (no row limit)">
307
+ Download CSV
308
+ </button>
309
+ </form>
306
310
  </div>
307
311
  </div>
308
312
  {% endif %}
@@ -19,4 +19,5 @@ urlpatterns = [
19
19
  ),
20
20
  path("ajax/save-query/", views.SavedQueryAjaxSave.as_view(), name="ajax_save_query"),
21
21
  path("ajax/list-queries/", views.SavedQueryAjaxList.as_view(), name="ajax_list_queries"),
22
+ path("export-csv/", views.CSVExportView.as_view(), name="export_csv"),
22
23
  ]
@@ -1,9 +1,12 @@
1
+ import csv
2
+ import io
1
3
  import json
2
4
  import logging
5
+ import re
3
6
 
4
7
  from django.contrib.auth.mixins import UserPassesTestMixin
5
8
  from django.db import DatabaseError, connection, transaction
6
- from django.http import JsonResponse
9
+ from django.http import HttpResponse, JsonResponse
7
10
  from django.views import View
8
11
  from django.views.generic import TemplateView
9
12
  from netbox.plugins import get_plugin_config
@@ -25,6 +28,7 @@ from .access import (
25
28
  from .filtersets import SavedQueryFilterSet
26
29
  from .forms import SavedQueryFilterForm, SavedQueryForm
27
30
  from .models import SavedQuery
31
+ from .query import execute_read_query, execute_write_query
28
32
  from .schema import get_abstract_schema, get_schema
29
33
  from .tables import SavedQueryTable
30
34
 
@@ -178,46 +182,33 @@ class QueryView(UserPassesTestMixin, TemplateView):
178
182
  return self.render_to_response(ctx)
179
183
 
180
184
  def _execute_read(self, ctx, sql, timeout_ms, max_rows):
181
- try:
182
- with transaction.atomic():
183
- with connection.cursor() as cursor:
184
- cursor.execute(f"SET LOCAL statement_timeout = '{timeout_ms}'")
185
- cursor.execute("SET TRANSACTION READ ONLY")
186
- cursor.execute(sql)
187
- columns = [col[0] for col in cursor.description]
188
- rows = cursor.fetchmany(max_rows)
189
- raise _ReadOnlyRollback()
190
- except _ReadOnlyRollback:
191
- ctx.update(columns=columns, rows=rows, row_count=len(rows))
192
- except DatabaseError as exc:
193
- ctx["error"] = str(exc)
185
+ result = execute_read_query(sql, timeout_ms, max_rows)
186
+ if result["error"]:
187
+ ctx["error"] = result["error"]
188
+ else:
189
+ ctx.update(
190
+ columns=result["columns"],
191
+ rows=result["rows"],
192
+ row_count=result["row_count"],
193
+ truncated=result["truncated"],
194
+ max_rows=max_rows,
195
+ )
194
196
  return ctx
195
197
 
196
198
  def _execute_write(self, ctx, sql, timeout_ms):
197
199
  max_rows = get_plugin_config("netbox_sqlquery", "max_rows")
198
- try:
199
- # Append RETURNING * if the user didn't include a RETURNING clause
200
- exec_sql = sql
201
- has_returning = "RETURNING" in sql.upper()
202
- normalized = sql.lstrip().upper()
203
- if not has_returning and (
204
- normalized.startswith("UPDATE") or normalized.startswith("DELETE")
205
- ):
206
- exec_sql = sql.rstrip().rstrip(";") + " RETURNING *"
207
-
208
- with connection.cursor() as cursor:
209
- cursor.execute(f"SET LOCAL statement_timeout = '{timeout_ms}'")
210
- cursor.execute(exec_sql)
211
- row_count = cursor.rowcount
212
-
213
- if cursor.description:
214
- columns = [col[0] for col in cursor.description]
215
- rows = cursor.fetchmany(max_rows)
216
- ctx.update(columns=columns, rows=rows, row_count=len(rows))
217
-
218
- ctx["write_result"] = f"{row_count} row{'s' if row_count != 1 else ''} affected."
219
- except DatabaseError as exc:
220
- 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."
221
212
  return ctx
222
213
 
223
214
 
@@ -293,7 +284,6 @@ class SavedQueryAjaxSave(UserPassesTestMixin, View):
293
284
  return JsonResponse({"error": "Name must be 100 characters or fewer."}, status=400)
294
285
 
295
286
  # Validate name against injection
296
- import re
297
287
 
298
288
  if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9 _\-\.]*$", name):
299
289
  return JsonResponse(
@@ -351,3 +341,57 @@ class SavedQueryAjaxList(UserPassesTestMixin, View):
351
341
  ]
352
342
  }
353
343
  )
344
+
345
+
346
+ class CSVExportView(UserPassesTestMixin, View):
347
+ """Export query results as CSV with no row limit."""
348
+
349
+ def test_func(self):
350
+ user = self.request.user
351
+ if not user.is_active:
352
+ return False
353
+ if user.is_superuser:
354
+ return True
355
+ if get_plugin_config("netbox_sqlquery", "require_superuser"):
356
+ return False
357
+ return user.has_perm("netbox_sqlquery.view_querypermission")
358
+
359
+ def post(self, request):
360
+ sql = request.POST.get("sql", "").strip()
361
+ if not sql:
362
+ return HttpResponse("No SQL provided.", status=400)
363
+
364
+ normalized = sql.lstrip().upper()
365
+ if not (normalized.startswith("SELECT") or normalized.startswith("WITH")):
366
+ return HttpResponse("Only SELECT queries can be exported.", status=400)
367
+
368
+ denied = check_access(request.user, extract_tables(sql))
369
+ if denied:
370
+ return HttpResponse(f"Access denied to: {', '.join(sorted(denied))}", status=403)
371
+
372
+ timeout_ms = get_plugin_config("netbox_sqlquery", "statement_timeout_ms")
373
+
374
+ try:
375
+ with transaction.atomic():
376
+ with connection.cursor() as cursor:
377
+ cursor.execute(f"SET LOCAL statement_timeout = '{timeout_ms}'")
378
+ cursor.execute("SET TRANSACTION READ ONLY")
379
+ cursor.execute(sql)
380
+ columns = [col[0] for col in cursor.description]
381
+ rows = cursor.fetchall()
382
+ raise _ReadOnlyRollback()
383
+ except _ReadOnlyRollback:
384
+ pass
385
+ except DatabaseError as exc:
386
+ return HttpResponse(f"Query error: {exc}", status=400)
387
+
388
+ output = io.StringIO()
389
+ writer = csv.writer(output)
390
+ writer.writerow(columns)
391
+ writer.writerows(rows)
392
+
393
+ response = HttpResponse(output.getvalue(), content_type="text/csv")
394
+ response["Content-Disposition"] = 'attachment; filename="query_results.csv"'
395
+
396
+ _record_query(request.user, sql)
397
+ return response
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netbox-sqlquery
3
- Version: 0.1.2
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
7
7
  Project-URL: Homepage, https://github.com/ravinald/netbox-sqlquery
8
8
  Project-URL: Issues, https://github.com/ravinald/netbox-sqlquery/issues
9
- Requires-Python: >=3.10
9
+ Requires-Python: >=3.12
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Dynamic: license-file
@@ -73,7 +73,7 @@ See [COMPATIBILITY.md](COMPATIBILITY.md) for the full version matrix.
73
73
 
74
74
  | NetBox version | Python versions |
75
75
  |----------------|------------------------------|
76
- | 4.0 - 4.5 | 3.10, 3.11, 3.12, 3.13, 3.14 |
76
+ | 4.5+ | 3.12, 3.13, 3.14 |
77
77
 
78
78
  ## Installation
79
79
 
@@ -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
@@ -26,7 +27,6 @@ netbox_sqlquery/management/commands/__init__.py
26
27
  netbox_sqlquery/management/commands/sqlquery_create_views.py
27
28
  netbox_sqlquery/migrations/0001_initial.py
28
29
  netbox_sqlquery/migrations/0002_query_permissions.py
29
- netbox_sqlquery/migrations/0003_tablepermission_groups_to_users_group.py
30
30
  netbox_sqlquery/migrations/__init__.py
31
31
  netbox_sqlquery/static/netbox_sqlquery/editor.js
32
32
  netbox_sqlquery/static/netbox_sqlquery/icon.LICENSE
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "netbox-sqlquery"
7
- version = "0.1.2"
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"
11
- requires-python = ">=3.10"
11
+ requires-python = ">=3.12"
12
12
  dependencies = []
13
13
  authors = [
14
14
  { name = "Ravi Pina", email = "ravi@pina.org" },
@@ -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)
@@ -1,16 +0,0 @@
1
- from django.db import migrations, models
2
-
3
-
4
- class Migration(migrations.Migration):
5
- dependencies = [
6
- ("netbox_sqlquery", "0002_query_permissions"),
7
- ("users", "0001_squashed_0011"),
8
- ]
9
-
10
- operations = [
11
- migrations.AlterField(
12
- model_name="tablepermission",
13
- name="groups",
14
- field=models.ManyToManyField(blank=True, to="users.group"),
15
- ),
16
- ]
File without changes