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,72 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from netbox.plugins import PluginConfig
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("netbox_sqlquery")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NetBoxSQLQueryConfig(PluginConfig):
|
|
9
|
+
name = "netbox_sqlquery"
|
|
10
|
+
verbose_name = "SQL Query Explorer"
|
|
11
|
+
description = (
|
|
12
|
+
"SQL query interface for NetBox with syntax highlighting,"
|
|
13
|
+
" abstract views, and role-based access control"
|
|
14
|
+
)
|
|
15
|
+
version = "0.1.0"
|
|
16
|
+
author = "Ravi Pina"
|
|
17
|
+
author_email = "ravi@pina.org"
|
|
18
|
+
base_url = "sqlquery"
|
|
19
|
+
min_version = "4.0.0"
|
|
20
|
+
max_version = None
|
|
21
|
+
required_settings = []
|
|
22
|
+
default_settings = {
|
|
23
|
+
"require_superuser": True,
|
|
24
|
+
"max_rows": 1000,
|
|
25
|
+
"statement_timeout_ms": 10_000,
|
|
26
|
+
"deny_tables": [
|
|
27
|
+
"auth_user",
|
|
28
|
+
"users_token",
|
|
29
|
+
"users_userconfig",
|
|
30
|
+
],
|
|
31
|
+
"top_level_menu": False,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Suppress auto-loading; we register navigation conditionally in ready()
|
|
35
|
+
menu = None
|
|
36
|
+
menu_items = None
|
|
37
|
+
|
|
38
|
+
def ready(self):
|
|
39
|
+
super().ready()
|
|
40
|
+
|
|
41
|
+
# Register navigation based on top_level_menu setting
|
|
42
|
+
self._register_navigation()
|
|
43
|
+
|
|
44
|
+
# Create abstract SQL views
|
|
45
|
+
try:
|
|
46
|
+
from .abstract_schema import ensure_views
|
|
47
|
+
ensure_views()
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
logger.warning(
|
|
50
|
+
"Could not create abstract SQL views: %s. "
|
|
51
|
+
"Run 'manage.py sqlquery_create_views' manually.",
|
|
52
|
+
exc,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _register_navigation(self):
|
|
56
|
+
from netbox.plugins.registration import register_menu, register_menu_items
|
|
57
|
+
|
|
58
|
+
from .navigation import get_menu, get_menu_items
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
from netbox.plugins import get_plugin_config
|
|
62
|
+
top_level = get_plugin_config("netbox_sqlquery", "top_level_menu")
|
|
63
|
+
except Exception:
|
|
64
|
+
top_level = False
|
|
65
|
+
|
|
66
|
+
if top_level:
|
|
67
|
+
register_menu(get_menu())
|
|
68
|
+
else:
|
|
69
|
+
register_menu_items(self.verbose_name, get_menu_items())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
config = NetBoxSQLQueryConfig
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generate PostgreSQL VIEWs that present NetBox data with resolved foreign keys
|
|
3
|
+
and aggregated M2M relationships (tags), matching how the GUI displays data.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from django.apps import apps
|
|
9
|
+
from django.db import connection
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("netbox_sqlquery")
|
|
12
|
+
|
|
13
|
+
# Apps to include in abstract views
|
|
14
|
+
INCLUDED_APPS = {
|
|
15
|
+
"circuits",
|
|
16
|
+
"dcim",
|
|
17
|
+
"ipam",
|
|
18
|
+
"tenancy",
|
|
19
|
+
"virtualization",
|
|
20
|
+
"vpn",
|
|
21
|
+
"wireless",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Models to skip (through tables, internal models, templates)
|
|
25
|
+
EXCLUDED_MODELS = {
|
|
26
|
+
"dcim.cablepath",
|
|
27
|
+
"dcim.cabletermination",
|
|
28
|
+
"dcim.consoleporttemplate",
|
|
29
|
+
"dcim.consoleserverporttemplate",
|
|
30
|
+
"dcim.devicebaytemplate",
|
|
31
|
+
"dcim.frontporttemplate",
|
|
32
|
+
"dcim.interfacetemplate",
|
|
33
|
+
"dcim.inventoryitemtemplate",
|
|
34
|
+
"dcim.modulebaytemplate",
|
|
35
|
+
"dcim.portmapping",
|
|
36
|
+
"dcim.porttemplatemapping",
|
|
37
|
+
"dcim.poweroutlettemplate",
|
|
38
|
+
"dcim.powerporttemplate",
|
|
39
|
+
"dcim.rearporttemplate",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Columns to skip (internal, denormalized, or handled specially)
|
|
43
|
+
SKIP_COLUMNS = {
|
|
44
|
+
"custom_field_data",
|
|
45
|
+
"lft",
|
|
46
|
+
"rght",
|
|
47
|
+
"tree_id",
|
|
48
|
+
"level",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# FK columns that are internal/denormalized and should not appear in the view
|
|
52
|
+
INTERNAL_FK_PREFIXES = ("_",)
|
|
53
|
+
|
|
54
|
+
# Override the display expression for specific target tables
|
|
55
|
+
# Default is to use the "name" column from the target table
|
|
56
|
+
FK_DISPLAY_OVERRIDES = {
|
|
57
|
+
"ipam_vlan": "CONCAT({alias}.vid, ' (', {alias}.name, ')')",
|
|
58
|
+
"django_content_type": "CONCAT({alias}.app_label, '.', {alias}.model)",
|
|
59
|
+
"users_owner": "{alias}.id",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Override the view name for specific models
|
|
63
|
+
VIEW_NAME_OVERRIDES = {
|
|
64
|
+
"ipam.ipaddress": "nb_ip_addresses",
|
|
65
|
+
"ipam.fhrpgroup": "nb_fhrp_groups",
|
|
66
|
+
"ipam.fhrpgroupassignment": "nb_fhrp_group_assignments",
|
|
67
|
+
"ipam.asnrange": "nb_asn_ranges",
|
|
68
|
+
"ipam.vlangroup": "nb_vlan_groups",
|
|
69
|
+
"ipam.vlantranslationpolicy": "nb_vlan_translation_policies",
|
|
70
|
+
"ipam.vlantranslationrule": "nb_vlan_translation_rules",
|
|
71
|
+
"ipam.routetarget": "nb_route_targets",
|
|
72
|
+
"ipam.servicetemplate": "nb_service_templates",
|
|
73
|
+
"dcim.devicebay": "nb_device_bays",
|
|
74
|
+
"dcim.devicerole": "nb_device_roles",
|
|
75
|
+
"dcim.devicetype": "nb_device_types",
|
|
76
|
+
"dcim.frontport": "nb_front_ports",
|
|
77
|
+
"dcim.consoleport": "nb_console_ports",
|
|
78
|
+
"dcim.consoleserverport": "nb_console_server_ports",
|
|
79
|
+
"dcim.inventoryitem": "nb_inventory_items",
|
|
80
|
+
"dcim.inventoryitemrole": "nb_inventory_item_roles",
|
|
81
|
+
"dcim.macaddress": "nb_mac_addresses",
|
|
82
|
+
"dcim.modulebay": "nb_module_bays",
|
|
83
|
+
"dcim.moduletype": "nb_module_types",
|
|
84
|
+
"dcim.moduletypeprofile": "nb_module_type_profiles",
|
|
85
|
+
"dcim.powerfeed": "nb_power_feeds",
|
|
86
|
+
"dcim.poweroutlet": "nb_power_outlets",
|
|
87
|
+
"dcim.powerpanel": "nb_power_panels",
|
|
88
|
+
"dcim.powerport": "nb_power_ports",
|
|
89
|
+
"dcim.rackreservation": "nb_rack_reservations",
|
|
90
|
+
"dcim.rackrole": "nb_rack_roles",
|
|
91
|
+
"dcim.racktype": "nb_rack_types",
|
|
92
|
+
"dcim.rearport": "nb_rear_ports",
|
|
93
|
+
"dcim.sitegroup": "nb_site_groups",
|
|
94
|
+
"dcim.virtualchassis": "nb_virtual_chassis",
|
|
95
|
+
"dcim.virtualdevicecontext": "nb_virtual_device_contexts",
|
|
96
|
+
"circuits.circuitgroup": "nb_circuit_groups",
|
|
97
|
+
"circuits.circuitgroupassignment": "nb_circuit_group_assignments",
|
|
98
|
+
"circuits.circuittype": "nb_circuit_types",
|
|
99
|
+
"circuits.circuittermination": "nb_circuit_terminations",
|
|
100
|
+
"circuits.provideraccount": "nb_provider_accounts",
|
|
101
|
+
"circuits.providernetwork": "nb_provider_networks",
|
|
102
|
+
"circuits.virtualcircuit": "nb_virtual_circuits",
|
|
103
|
+
"circuits.virtualcircuittype": "nb_virtual_circuit_types",
|
|
104
|
+
"circuits.virtualcircuittermination": "nb_virtual_circuit_terminations",
|
|
105
|
+
"tenancy.contactassignment": "nb_contact_assignments",
|
|
106
|
+
"tenancy.contactgroup": "nb_contact_groups",
|
|
107
|
+
"tenancy.contactrole": "nb_contact_roles",
|
|
108
|
+
"tenancy.tenantgroup": "nb_tenant_groups",
|
|
109
|
+
"virtualization.clustergroup": "nb_cluster_groups",
|
|
110
|
+
"virtualization.clustertype": "nb_cluster_types",
|
|
111
|
+
"virtualization.virtualdisk": "nb_virtual_disks",
|
|
112
|
+
"virtualization.virtualmachine": "nb_virtual_machines",
|
|
113
|
+
"virtualization.vminterface": "nb_vm_interfaces",
|
|
114
|
+
"vpn.ikepolicy": "nb_ike_policies",
|
|
115
|
+
"vpn.ikeproposal": "nb_ike_proposals",
|
|
116
|
+
"vpn.ipsecpolicy": "nb_ipsec_policies",
|
|
117
|
+
"vpn.ipsecprofile": "nb_ipsec_profiles",
|
|
118
|
+
"vpn.ipsecproposal": "nb_ipsec_proposals",
|
|
119
|
+
"vpn.l2vpn": "nb_l2vpns",
|
|
120
|
+
"vpn.l2vpntermination": "nb_l2vpn_terminations",
|
|
121
|
+
"vpn.tunnelgroup": "nb_tunnel_groups",
|
|
122
|
+
"vpn.tunneltermination": "nb_tunnel_terminations",
|
|
123
|
+
"wireless.wirelesslan": "nb_wireless_lans",
|
|
124
|
+
"wireless.wirelesslangroup": "nb_wireless_lan_groups",
|
|
125
|
+
"wireless.wirelesslink": "nb_wireless_links",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Column renames (internal name -> friendly name)
|
|
129
|
+
COLUMN_RENAMES = {
|
|
130
|
+
"_children": "children",
|
|
131
|
+
"_depth": "depth",
|
|
132
|
+
"_name": "name",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Maps nb_* view name -> set of underlying table names (populated by ensure_views)
|
|
136
|
+
ABSTRACT_TO_TABLES = {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _get_view_name(model):
|
|
140
|
+
"""Generate the nb_* view name for a model."""
|
|
141
|
+
label = f"{model._meta.app_label}.{model._meta.model_name}"
|
|
142
|
+
if label in VIEW_NAME_OVERRIDES:
|
|
143
|
+
return VIEW_NAME_OVERRIDES[label]
|
|
144
|
+
# Default: nb_ + verbose_name_plural with spaces/hyphens replaced by underscores
|
|
145
|
+
plural = str(model._meta.verbose_name_plural).lower()
|
|
146
|
+
slug = plural.replace(" ", "_").replace("-", "_")
|
|
147
|
+
return f"nb_{slug}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _get_table_columns(table_name):
|
|
151
|
+
"""Get column info from information_schema for a table."""
|
|
152
|
+
with connection.cursor() as cursor:
|
|
153
|
+
cursor.execute("""
|
|
154
|
+
SELECT column_name, data_type, is_nullable
|
|
155
|
+
FROM information_schema.columns
|
|
156
|
+
WHERE table_schema = 'public' AND table_name = %s
|
|
157
|
+
ORDER BY ordinal_position
|
|
158
|
+
""", [table_name])
|
|
159
|
+
return cursor.fetchall()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _get_fk_map(table_name):
|
|
163
|
+
"""Get FK column -> target table mapping from information_schema."""
|
|
164
|
+
with connection.cursor() as cursor:
|
|
165
|
+
cursor.execute("""
|
|
166
|
+
SELECT kcu.column_name, ccu.table_name
|
|
167
|
+
FROM information_schema.table_constraints tc
|
|
168
|
+
JOIN information_schema.key_column_usage kcu
|
|
169
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
170
|
+
AND tc.table_schema = kcu.table_schema
|
|
171
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
172
|
+
ON tc.constraint_name = ccu.constraint_name
|
|
173
|
+
AND tc.table_schema = ccu.table_schema
|
|
174
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
175
|
+
AND tc.table_schema = 'public'
|
|
176
|
+
AND kcu.table_name = %s
|
|
177
|
+
""", [table_name])
|
|
178
|
+
return {row[0]: row[1] for row in cursor.fetchall()}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _target_has_column(table_name, column_name):
|
|
182
|
+
"""Check if a table has a specific column."""
|
|
183
|
+
with connection.cursor() as cursor:
|
|
184
|
+
cursor.execute("""
|
|
185
|
+
SELECT 1 FROM information_schema.columns
|
|
186
|
+
WHERE table_schema = 'public'
|
|
187
|
+
AND table_name = %s
|
|
188
|
+
AND column_name = %s
|
|
189
|
+
LIMIT 1
|
|
190
|
+
""", [table_name, column_name])
|
|
191
|
+
return cursor.fetchone() is not None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _has_tags(model):
|
|
195
|
+
"""Check if a model uses NetBox's tagging system."""
|
|
196
|
+
for field in model._meta.get_fields():
|
|
197
|
+
if hasattr(field, "related_model") and field.related_model is not None:
|
|
198
|
+
related = field.related_model
|
|
199
|
+
if related._meta.db_table == "extras_taggeditem":
|
|
200
|
+
return True
|
|
201
|
+
if (related._meta.app_label == "extras"
|
|
202
|
+
and related._meta.model_name == "taggeditem"):
|
|
203
|
+
return True
|
|
204
|
+
# Also check via the through table pattern
|
|
205
|
+
try:
|
|
206
|
+
model._meta.get_field("tags")
|
|
207
|
+
return True
|
|
208
|
+
except Exception:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_view_sql(model):
|
|
213
|
+
"""
|
|
214
|
+
Build a CREATE OR REPLACE VIEW statement for a Django model.
|
|
215
|
+
Returns (view_name, sql, underlying_tables) or None if the model should be skipped.
|
|
216
|
+
"""
|
|
217
|
+
db_table = model._meta.db_table
|
|
218
|
+
view_name = _get_view_name(model)
|
|
219
|
+
alias = "t"
|
|
220
|
+
|
|
221
|
+
columns_info = _get_table_columns(db_table)
|
|
222
|
+
if not columns_info:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
fk_map = _get_fk_map(db_table)
|
|
226
|
+
|
|
227
|
+
select_parts = []
|
|
228
|
+
join_parts = []
|
|
229
|
+
underlying_tables = {db_table}
|
|
230
|
+
join_counter = 0
|
|
231
|
+
used_names = set()
|
|
232
|
+
|
|
233
|
+
def _unique_name(name):
|
|
234
|
+
"""Ensure output column names are unique by appending _2, _3, etc."""
|
|
235
|
+
if name not in used_names:
|
|
236
|
+
used_names.add(name)
|
|
237
|
+
return name
|
|
238
|
+
i = 2
|
|
239
|
+
while f"{name}_{i}" in used_names:
|
|
240
|
+
i += 1
|
|
241
|
+
unique = f"{name}_{i}"
|
|
242
|
+
used_names.add(unique)
|
|
243
|
+
return unique
|
|
244
|
+
|
|
245
|
+
for col_name, col_type, is_nullable in columns_info:
|
|
246
|
+
if col_name in SKIP_COLUMNS:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Determine the output column name
|
|
250
|
+
out_name = COLUMN_RENAMES.get(col_name, col_name)
|
|
251
|
+
|
|
252
|
+
# Skip internal FK columns (prefixed with _) that point to denormalized caches
|
|
253
|
+
if col_name.startswith("_") and col_name in fk_map:
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
if col_name in fk_map and col_name.endswith("_id"):
|
|
257
|
+
target_table = fk_map[col_name]
|
|
258
|
+
friendly_name = col_name[:-3] # strip _id
|
|
259
|
+
|
|
260
|
+
# Skip content_type FK for generic foreign keys (handled separately)
|
|
261
|
+
if target_table == "django_content_type" and col_name == "scope_type_id":
|
|
262
|
+
join_counter += 1
|
|
263
|
+
j_alias = f"j{join_counter}"
|
|
264
|
+
expr = FK_DISPLAY_OVERRIDES["django_content_type"].format(alias=j_alias)
|
|
265
|
+
col_alias = _unique_name("scope_type")
|
|
266
|
+
select_parts.append(f" {expr} AS {col_alias}")
|
|
267
|
+
join_parts.append(
|
|
268
|
+
f"LEFT JOIN {target_table} {j_alias} ON {j_alias}.id = {alias}.{col_name}"
|
|
269
|
+
)
|
|
270
|
+
underlying_tables.add(target_table)
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
if target_table == "django_content_type":
|
|
274
|
+
join_counter += 1
|
|
275
|
+
j_alias = f"j{join_counter}"
|
|
276
|
+
ct_friendly = col_name.replace("_id", "").replace("content_type", "type")
|
|
277
|
+
expr = FK_DISPLAY_OVERRIDES["django_content_type"].format(alias=j_alias)
|
|
278
|
+
col_alias = _unique_name(ct_friendly)
|
|
279
|
+
select_parts.append(f" {expr} AS {col_alias}")
|
|
280
|
+
join_parts.append(
|
|
281
|
+
f"LEFT JOIN {target_table} {j_alias} ON {j_alias}.id = {alias}.{col_name}"
|
|
282
|
+
)
|
|
283
|
+
underlying_tables.add(target_table)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
# Resolve FK to display value
|
|
287
|
+
join_counter += 1
|
|
288
|
+
j_alias = f"j{join_counter}"
|
|
289
|
+
underlying_tables.add(target_table)
|
|
290
|
+
|
|
291
|
+
if target_table in FK_DISPLAY_OVERRIDES:
|
|
292
|
+
expr = FK_DISPLAY_OVERRIDES[target_table].format(alias=j_alias)
|
|
293
|
+
elif _target_has_column(target_table, "name"):
|
|
294
|
+
expr = f"{j_alias}.name"
|
|
295
|
+
elif _target_has_column(target_table, "_name"):
|
|
296
|
+
expr = f"{j_alias}._name"
|
|
297
|
+
else:
|
|
298
|
+
# No name column -- fall back to showing the ID
|
|
299
|
+
col_alias = _unique_name(friendly_name)
|
|
300
|
+
select_parts.append(f" {alias}.{col_name} AS {col_alias}")
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
col_alias = _unique_name(friendly_name)
|
|
304
|
+
select_parts.append(f" {expr} AS {col_alias}")
|
|
305
|
+
join_parts.append(
|
|
306
|
+
f"LEFT JOIN {target_table} {j_alias} ON {j_alias}.id = {alias}.{col_name}"
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
# Regular column
|
|
310
|
+
col_alias = _unique_name(out_name)
|
|
311
|
+
if col_alias != col_name:
|
|
312
|
+
select_parts.append(f" {alias}.{col_name} AS {col_alias}")
|
|
313
|
+
else:
|
|
314
|
+
select_parts.append(f" {alias}.{col_name}")
|
|
315
|
+
|
|
316
|
+
# Add tags subquery if the model supports tags
|
|
317
|
+
if _has_tags(model):
|
|
318
|
+
app_label = model._meta.app_label
|
|
319
|
+
model_name = model._meta.model_name
|
|
320
|
+
select_parts.append(f""" (SELECT STRING_AGG(tag.name, ', ' ORDER BY tag.name)
|
|
321
|
+
FROM extras_taggeditem ti
|
|
322
|
+
JOIN extras_tag tag ON tag.id = ti.tag_id
|
|
323
|
+
WHERE ti.content_type_id = (
|
|
324
|
+
SELECT id FROM django_content_type
|
|
325
|
+
WHERE app_label = '{app_label}' AND model = '{model_name}'
|
|
326
|
+
)
|
|
327
|
+
AND ti.object_id = {alias}.id
|
|
328
|
+
) AS tags""")
|
|
329
|
+
underlying_tables.add("extras_taggeditem")
|
|
330
|
+
underlying_tables.add("extras_tag")
|
|
331
|
+
|
|
332
|
+
select_clause = ",\n".join(select_parts)
|
|
333
|
+
from_clause = f"{db_table} {alias}"
|
|
334
|
+
join_clause = "\n".join(join_parts)
|
|
335
|
+
|
|
336
|
+
sql = f"CREATE OR REPLACE VIEW {view_name} AS\nSELECT\n{select_clause}\nFROM {from_clause}"
|
|
337
|
+
if join_clause:
|
|
338
|
+
sql += f"\n{join_clause}"
|
|
339
|
+
sql += ";"
|
|
340
|
+
|
|
341
|
+
return view_name, sql, underlying_tables
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def get_included_models():
|
|
345
|
+
"""Return all Django models that should get abstract views."""
|
|
346
|
+
models = []
|
|
347
|
+
for model in apps.get_models():
|
|
348
|
+
app_label = model._meta.app_label
|
|
349
|
+
if app_label not in INCLUDED_APPS:
|
|
350
|
+
continue
|
|
351
|
+
label = f"{app_label}.{model._meta.model_name}"
|
|
352
|
+
if label in EXCLUDED_MODELS:
|
|
353
|
+
continue
|
|
354
|
+
# Skip auto-created through tables for M2M fields
|
|
355
|
+
if model._meta.auto_created:
|
|
356
|
+
continue
|
|
357
|
+
models.append(model)
|
|
358
|
+
return models
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def ensure_views(dry_run=False):
|
|
362
|
+
"""Create or replace all abstract views. Returns list of (view_name, sql) tuples."""
|
|
363
|
+
results = []
|
|
364
|
+
ABSTRACT_TO_TABLES.clear()
|
|
365
|
+
|
|
366
|
+
models = get_included_models()
|
|
367
|
+
|
|
368
|
+
for model in models:
|
|
369
|
+
try:
|
|
370
|
+
result = build_view_sql(model)
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
logger.warning("Failed to build view for %s: %s", model._meta.label, exc)
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
if result is None:
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
view_name, sql, underlying_tables = result
|
|
379
|
+
ABSTRACT_TO_TABLES[view_name] = underlying_tables
|
|
380
|
+
results.append((view_name, sql))
|
|
381
|
+
|
|
382
|
+
if not dry_run:
|
|
383
|
+
try:
|
|
384
|
+
with connection.cursor() as cursor:
|
|
385
|
+
cursor.execute(sql)
|
|
386
|
+
logger.debug("Created view %s", view_name)
|
|
387
|
+
except Exception as exc:
|
|
388
|
+
logger.warning("Failed to create view %s: %s", view_name, exc)
|
|
389
|
+
|
|
390
|
+
return results
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def drop_views():
|
|
394
|
+
"""Drop all nb_* views."""
|
|
395
|
+
with connection.cursor() as cursor:
|
|
396
|
+
cursor.execute("""
|
|
397
|
+
SELECT table_name FROM information_schema.views
|
|
398
|
+
WHERE table_schema = 'public' AND table_name LIKE 'nb\\_%'
|
|
399
|
+
ORDER BY table_name
|
|
400
|
+
""")
|
|
401
|
+
view_names = [row[0] for row in cursor.fetchall()]
|
|
402
|
+
for name in view_names:
|
|
403
|
+
cursor.execute(f"DROP VIEW IF EXISTS {name} CASCADE")
|
|
404
|
+
logger.info("Dropped view %s", name)
|
|
405
|
+
ABSTRACT_TO_TABLES.clear()
|
|
406
|
+
return view_names
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from netbox.plugins import get_plugin_config
|
|
4
|
+
|
|
5
|
+
from .models import TablePermission
|
|
6
|
+
|
|
7
|
+
ALL_TABLES = object()
|
|
8
|
+
|
|
9
|
+
# Menu-group-based table access: each entry maps a NetBox permission to
|
|
10
|
+
# a set of table prefixes the user can query if they hold that permission.
|
|
11
|
+
MENU_GROUP_TABLE_MAP = {
|
|
12
|
+
"dcim.view_site": {
|
|
13
|
+
"dcim_region", "dcim_sitegroup", "dcim_site", "dcim_location",
|
|
14
|
+
"tenancy_tenant", "tenancy_tenantgroup",
|
|
15
|
+
"tenancy_contact", "tenancy_contactgroup", "tenancy_contactrole",
|
|
16
|
+
"tenancy_contactassignment",
|
|
17
|
+
},
|
|
18
|
+
"dcim.view_rack": {
|
|
19
|
+
"dcim_rack", "dcim_rackrole", "dcim_rackreservation", "dcim_racktype",
|
|
20
|
+
},
|
|
21
|
+
"dcim.view_device": {
|
|
22
|
+
"dcim_device", "dcim_devicebay", "dcim_devicerole", "dcim_devicetype",
|
|
23
|
+
"dcim_module", "dcim_modulebay", "dcim_moduletype", "dcim_moduletypeprofile",
|
|
24
|
+
"dcim_manufacturer", "dcim_platform",
|
|
25
|
+
"dcim_interface", "dcim_frontport", "dcim_rearport",
|
|
26
|
+
"dcim_consoleport", "dcim_consoleserverport",
|
|
27
|
+
"dcim_powerport", "dcim_poweroutlet",
|
|
28
|
+
"dcim_inventoryitem", "dcim_inventoryitemrole",
|
|
29
|
+
"dcim_virtualchassis", "dcim_virtualdevicecontext",
|
|
30
|
+
"dcim_macaddress", "dcim_cable",
|
|
31
|
+
},
|
|
32
|
+
"dcim.view_powerfeed": {
|
|
33
|
+
"dcim_powerfeed", "dcim_powerpanel",
|
|
34
|
+
},
|
|
35
|
+
"ipam.view_ipaddress": {
|
|
36
|
+
"ipam_aggregate", "ipam_asn", "ipam_asnrange",
|
|
37
|
+
"ipam_fhrpgroup", "ipam_fhrpgroupassignment",
|
|
38
|
+
"ipam_ipaddress", "ipam_iprange", "ipam_prefix",
|
|
39
|
+
"ipam_rir", "ipam_role", "ipam_routetarget",
|
|
40
|
+
"ipam_service", "ipam_servicetemplate",
|
|
41
|
+
"ipam_vlan", "ipam_vlangroup",
|
|
42
|
+
"ipam_vlantranslationpolicy", "ipam_vlantranslationrule",
|
|
43
|
+
"ipam_vrf",
|
|
44
|
+
},
|
|
45
|
+
"circuits.view_circuit": {
|
|
46
|
+
"circuits_circuit", "circuits_circuitgroup",
|
|
47
|
+
"circuits_circuitgroupassignment", "circuits_circuittermination",
|
|
48
|
+
"circuits_circuittype", "circuits_provider",
|
|
49
|
+
"circuits_provideraccount", "circuits_providernetwork",
|
|
50
|
+
"circuits_virtualcircuit", "circuits_virtualcircuittype",
|
|
51
|
+
"circuits_virtualcircuittermination",
|
|
52
|
+
},
|
|
53
|
+
"virtualization.view_virtualmachine": {
|
|
54
|
+
"virtualization_cluster", "virtualization_clustergroup",
|
|
55
|
+
"virtualization_clustertype", "virtualization_virtualdisk",
|
|
56
|
+
"virtualization_virtualmachine", "virtualization_vminterface",
|
|
57
|
+
},
|
|
58
|
+
"vpn.view_tunnel": {
|
|
59
|
+
"vpn_ikepolicy", "vpn_ikeproposal",
|
|
60
|
+
"vpn_ipsecpolicy", "vpn_ipsecprofile", "vpn_ipsecproposal",
|
|
61
|
+
"vpn_l2vpn", "vpn_l2vpntermination",
|
|
62
|
+
"vpn_tunnel", "vpn_tunnelgroup", "vpn_tunneltermination",
|
|
63
|
+
},
|
|
64
|
+
"wireless.view_wirelesslan": {
|
|
65
|
+
"wireless_wirelesslan", "wireless_wirelesslangroup", "wireless_wirelesslink",
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Shared tables always accessible to any authenticated plugin user.
|
|
70
|
+
# These are referenced by abstract views via JOINs for common relationships
|
|
71
|
+
# that span multiple apps.
|
|
72
|
+
SHARED_TABLES = {
|
|
73
|
+
"django_content_type",
|
|
74
|
+
"extras_tag", "extras_taggeditem", "extras_configtemplate",
|
|
75
|
+
"users_owner", "users_ownergroup",
|
|
76
|
+
"tenancy_tenant", "tenancy_tenantgroup",
|
|
77
|
+
"tenancy_contact", "tenancy_contactgroup", "tenancy_contactrole",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_tables(sql):
|
|
82
|
+
"""Extract table names referenced in FROM, JOIN, INTO, and UPDATE clauses.
|
|
83
|
+
|
|
84
|
+
For abstract views (nb_* names), expands to the set of underlying tables
|
|
85
|
+
so access control checks apply against the real tables.
|
|
86
|
+
"""
|
|
87
|
+
from .abstract_schema import ABSTRACT_TO_TABLES
|
|
88
|
+
|
|
89
|
+
pattern = r"\b(?:FROM|JOIN|INTO|UPDATE)\s+([\w_]+)"
|
|
90
|
+
raw_tables = set(re.findall(pattern, sql, re.IGNORECASE))
|
|
91
|
+
|
|
92
|
+
expanded = set()
|
|
93
|
+
for t in raw_tables:
|
|
94
|
+
if t in ABSTRACT_TO_TABLES:
|
|
95
|
+
expanded.update(ABSTRACT_TO_TABLES[t])
|
|
96
|
+
else:
|
|
97
|
+
expanded.add(t)
|
|
98
|
+
return expanded
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_access(user, tables):
|
|
102
|
+
"""
|
|
103
|
+
Returns the subset of tables the user is not permitted to query.
|
|
104
|
+
An empty set means all requested tables are accessible.
|
|
105
|
+
"""
|
|
106
|
+
denied = _hard_denies(tables)
|
|
107
|
+
if denied:
|
|
108
|
+
return denied
|
|
109
|
+
allowed = _allowed_tables(user)
|
|
110
|
+
if allowed is ALL_TABLES:
|
|
111
|
+
return set()
|
|
112
|
+
return tables - allowed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _hard_denies(tables):
|
|
116
|
+
deny_list = set(get_plugin_config("netbox_sqlquery", "deny_tables"))
|
|
117
|
+
return tables & deny_list
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _allowed_tables(user):
|
|
121
|
+
if user.is_superuser:
|
|
122
|
+
return ALL_TABLES
|
|
123
|
+
|
|
124
|
+
allowed = set(SHARED_TABLES)
|
|
125
|
+
|
|
126
|
+
# Menu-group-based access: check NetBox permissions
|
|
127
|
+
for perm, tables in MENU_GROUP_TABLE_MAP.items():
|
|
128
|
+
if user.has_perm(perm):
|
|
129
|
+
allowed.update(tables)
|
|
130
|
+
|
|
131
|
+
# TablePermission overrides (explicit allow/deny per table)
|
|
132
|
+
user_groups = set(user.groups.values_list("pk", flat=True))
|
|
133
|
+
for perm in TablePermission.objects.all():
|
|
134
|
+
if perm.require_superuser and not user.is_superuser:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
perm_groups = set(perm.groups.values_list("pk", flat=True))
|
|
138
|
+
if perm_groups and not (perm_groups & user_groups):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if perm.allow:
|
|
142
|
+
allowed.add(perm.pattern)
|
|
143
|
+
else:
|
|
144
|
+
allowed.discard(perm.pattern)
|
|
145
|
+
|
|
146
|
+
return allowed
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _hard_denies_set():
|
|
150
|
+
return set(get_plugin_config("netbox_sqlquery", "deny_tables"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def can_execute_write(user):
|
|
154
|
+
"""Check if user can run write queries (requires 'change' on QueryPermission)."""
|
|
155
|
+
if user.is_superuser:
|
|
156
|
+
return True
|
|
157
|
+
return user.has_perm("netbox_sqlquery.change_querypermission")
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from rest_framework import serializers
|
|
2
|
+
|
|
3
|
+
from netbox_sqlquery.models import SavedQuery
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SavedQuerySerializer(serializers.ModelSerializer):
|
|
7
|
+
class Meta:
|
|
8
|
+
model = SavedQuery
|
|
9
|
+
fields = [
|
|
10
|
+
"id",
|
|
11
|
+
"name",
|
|
12
|
+
"description",
|
|
13
|
+
"sql",
|
|
14
|
+
"owner",
|
|
15
|
+
"visibility",
|
|
16
|
+
"created",
|
|
17
|
+
"last_run",
|
|
18
|
+
"run_count",
|
|
19
|
+
]
|
|
20
|
+
read_only_fields = ["created", "last_run", "run_count"]
|
|
21
|
+
|
|
22
|
+
def validate_owner(self, value):
|
|
23
|
+
request = self.context.get("request")
|
|
24
|
+
if request and value != request.user:
|
|
25
|
+
raise serializers.ValidationError("You cannot set owner to another user.")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
def validate_visibility(self, value):
|
|
29
|
+
request = self.context.get("request")
|
|
30
|
+
if value == SavedQuery.VISIBILITY_GLOBAL_EDITABLE:
|
|
31
|
+
if request and not request.user.is_staff:
|
|
32
|
+
raise serializers.ValidationError(
|
|
33
|
+
"Only staff users can set global-editable visibility."
|
|
34
|
+
)
|
|
35
|
+
return value
|