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.
@@ -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
@@ -0,0 +1,8 @@
1
+ from rest_framework.routers import DefaultRouter
2
+
3
+ from .views import SavedQueryViewSet
4
+
5
+ router = DefaultRouter()
6
+ router.register("saved-queries", SavedQueryViewSet, basename="savedquery")
7
+
8
+ urlpatterns = router.urls