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.
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/PKG-INFO +3 -3
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/README.md +1 -1
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/__init__.py +2 -2
- netbox_sqlquery-0.1.4/netbox_sqlquery/api/views.py +107 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/0001_initial.py +2 -2
- netbox_sqlquery-0.1.4/netbox_sqlquery/query.py +115 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/editor.js +1 -27
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/templates/netbox_sqlquery/query.html +8 -4
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/urls.py +1 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/views.py +82 -38
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/PKG-INFO +3 -3
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/SOURCES.txt +1 -1
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/pyproject.toml +2 -2
- netbox_sqlquery-0.1.2/netbox_sqlquery/api/views.py +0 -15
- netbox_sqlquery-0.1.2/netbox_sqlquery/migrations/0003_tablepermission_groups_to_users_group.py +0 -16
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/LICENSE +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/abstract_schema.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/access.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/api/__init__.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/api/serializers.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/api/urls.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/filtersets.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/forms.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/__init__.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/commands/__init__.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/commands/sqlquery_create_views.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/0002_query_permissions.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/__init__.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/models.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/navigation.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/preferences.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/schema.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/icon.LICENSE +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/icon.svg +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tables.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/templates/netbox_sqlquery/saved_query.html +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/__init__.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_access.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_api.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_models.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/tests/test_views.py +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/dependency_links.txt +0 -0
- {netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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="
|
|
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
|
+
)
|
{netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/editor.js
RENAMED
|
@@ -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;
|
{netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/templates/netbox_sqlquery/query.html
RENAMED
|
@@ -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
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
netbox_sqlquery-0.1.2/netbox_sqlquery/migrations/0003_tablepermission_groups_to_users_group.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/management/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/migrations/0002_query_permissions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/icon.LICENSE
RENAMED
|
File without changes
|
{netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery/static/netbox_sqlquery/icon.svg
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{netbox_sqlquery-0.1.2 → netbox_sqlquery-0.1.4}/netbox_sqlquery.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|