catocli 2.1.3__py3-none-any.whl → 2.1.6__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.

Potentially problematic release.


This version of catocli might be problematic. Click here for more details.

Files changed (107) hide show
  1. catocli/Utils/clidriver.py +20 -9
  2. catocli/Utils/cliutils.py +45 -17
  3. catocli/Utils/csv_formatter.py +652 -0
  4. catocli/__init__.py +2 -2
  5. catocli/clisettings.json +35 -0
  6. catocli/parsers/custom/export_rules/__init__.py +0 -4
  7. catocli/parsers/custom/export_sites/__init__.py +17 -5
  8. catocli/parsers/custom/export_sites/export_sites.py +826 -53
  9. catocli/parsers/custom/import_sites_to_tf/__init__.py +44 -16
  10. catocli/parsers/custom/import_sites_to_tf/import_sites_to_tf.py +859 -442
  11. catocli/parsers/customParserApiClient.py +444 -38
  12. catocli/parsers/custom_private/__init__.py +18 -0
  13. catocli/parsers/mutation_accountManagement/__init__.py +21 -0
  14. catocli/parsers/mutation_accountManagement_disableAccount/README.md +15 -0
  15. catocli/parsers/mutation_admin/__init__.py +12 -0
  16. catocli/parsers/mutation_container/__init__.py +18 -0
  17. catocli/parsers/mutation_enterpriseDirectory/__init__.py +8 -0
  18. catocli/parsers/mutation_groups/__init__.py +6 -0
  19. catocli/parsers/mutation_hardware/__init__.py +2 -0
  20. catocli/parsers/mutation_licensing/__init__.py +24 -0
  21. catocli/parsers/mutation_licensing_updateCommercialLicense/README.md +19 -0
  22. catocli/parsers/mutation_policy/__init__.py +861 -483
  23. catocli/parsers/mutation_policy_antiMalwareFileHash_addRule/README.md +20 -0
  24. catocli/parsers/mutation_policy_antiMalwareFileHash_addSection/README.md +20 -0
  25. catocli/parsers/mutation_policy_antiMalwareFileHash_createPolicyRevision/README.md +20 -0
  26. catocli/parsers/mutation_policy_antiMalwareFileHash_discardPolicyRevision/README.md +20 -0
  27. catocli/parsers/mutation_policy_antiMalwareFileHash_moveRule/README.md +20 -0
  28. catocli/parsers/mutation_policy_antiMalwareFileHash_moveSection/README.md +20 -0
  29. catocli/parsers/mutation_policy_antiMalwareFileHash_publishPolicyRevision/README.md +20 -0
  30. catocli/parsers/mutation_policy_antiMalwareFileHash_removeRule/README.md +20 -0
  31. catocli/parsers/mutation_policy_antiMalwareFileHash_removeSection/README.md +20 -0
  32. catocli/parsers/mutation_policy_antiMalwareFileHash_updatePolicy/README.md +20 -0
  33. catocli/parsers/mutation_policy_antiMalwareFileHash_updateRule/README.md +20 -0
  34. catocli/parsers/mutation_policy_antiMalwareFileHash_updateSection/README.md +20 -0
  35. catocli/parsers/mutation_sandbox/__init__.py +4 -0
  36. catocli/parsers/mutation_site/__init__.py +72 -0
  37. catocli/parsers/mutation_sites/__init__.py +72 -0
  38. catocli/parsers/mutation_xdr/__init__.py +6 -0
  39. catocli/parsers/query_accountBySubdomain/__init__.py +2 -0
  40. catocli/parsers/query_accountManagement/__init__.py +2 -0
  41. catocli/parsers/query_accountMetrics/__init__.py +6 -0
  42. catocli/parsers/query_accountRoles/__init__.py +2 -0
  43. catocli/parsers/query_accountSnapshot/__init__.py +2 -0
  44. catocli/parsers/query_admin/__init__.py +2 -0
  45. catocli/parsers/query_admins/__init__.py +2 -0
  46. catocli/parsers/query_appStats/__init__.py +6 -0
  47. catocli/parsers/query_appStatsTimeSeries/README.md +3 -0
  48. catocli/parsers/query_appStatsTimeSeries/__init__.py +6 -0
  49. catocli/parsers/query_auditFeed/__init__.py +2 -0
  50. catocli/parsers/query_catalogs/__init__.py +2 -0
  51. catocli/parsers/query_container/__init__.py +2 -0
  52. catocli/parsers/query_devices/README.md +1 -1
  53. catocli/parsers/query_devices/__init__.py +2 -0
  54. catocli/parsers/query_enterpriseDirectory/__init__.py +2 -0
  55. catocli/parsers/query_entityLookup/__init__.py +2 -0
  56. catocli/parsers/query_events/__init__.py +2 -0
  57. catocli/parsers/query_eventsFeed/README.md +1 -1
  58. catocli/parsers/query_eventsFeed/__init__.py +2 -0
  59. catocli/parsers/query_eventsTimeSeries/__init__.py +2 -0
  60. catocli/parsers/query_groups/__init__.py +6 -0
  61. catocli/parsers/query_hardware/README.md +1 -1
  62. catocli/parsers/query_hardware/__init__.py +2 -0
  63. catocli/parsers/query_hardwareManagement/__init__.py +2 -0
  64. catocli/parsers/query_licensing/__init__.py +2 -0
  65. catocli/parsers/query_policy/__init__.py +85 -48
  66. catocli/parsers/query_policy_antiMalwareFileHash_policy/README.md +19 -0
  67. catocli/parsers/query_popLocations/__init__.py +2 -0
  68. catocli/parsers/query_sandbox/__init__.py +2 -0
  69. catocli/parsers/query_servicePrincipalAdmin/__init__.py +2 -0
  70. catocli/parsers/query_site/__init__.py +33 -0
  71. catocli/parsers/query_siteLocation/__init__.py +2 -0
  72. catocli/parsers/query_site_siteGeneralDetails/README.md +19 -0
  73. catocli/parsers/query_socketPortMetrics/__init__.py +2 -0
  74. catocli/parsers/query_socketPortMetricsTimeSeries/__init__.py +6 -0
  75. catocli/parsers/query_subDomains/__init__.py +2 -0
  76. catocli/parsers/query_xdr/__init__.py +4 -0
  77. catocli/parsers/raw/__init__.py +3 -1
  78. {catocli-2.1.3.dist-info → catocli-2.1.6.dist-info}/METADATA +1 -1
  79. {catocli-2.1.3.dist-info → catocli-2.1.6.dist-info}/RECORD +107 -72
  80. models/mutation.accountManagement.disableAccount.json +545 -0
  81. models/mutation.licensing.updateCommercialLicense.json +931 -0
  82. models/mutation.policy.antiMalwareFileHash.addRule.json +2068 -0
  83. models/mutation.policy.antiMalwareFileHash.addSection.json +1350 -0
  84. models/mutation.policy.antiMalwareFileHash.createPolicyRevision.json +1822 -0
  85. models/mutation.policy.antiMalwareFileHash.discardPolicyRevision.json +1758 -0
  86. models/mutation.policy.antiMalwareFileHash.moveRule.json +1552 -0
  87. models/mutation.policy.antiMalwareFileHash.moveSection.json +1251 -0
  88. models/mutation.policy.antiMalwareFileHash.publishPolicyRevision.json +1813 -0
  89. models/mutation.policy.antiMalwareFileHash.removeRule.json +1204 -0
  90. models/mutation.policy.antiMalwareFileHash.removeSection.json +954 -0
  91. models/mutation.policy.antiMalwareFileHash.updatePolicy.json +1834 -0
  92. models/mutation.policy.antiMalwareFileHash.updateRule.json +1757 -0
  93. models/mutation.policy.antiMalwareFileHash.updateSection.json +1105 -0
  94. models/mutation.site.updateSiteGeneralDetails.json +3 -3
  95. models/mutation.sites.updateSiteGeneralDetails.json +3 -3
  96. models/query.devices.json +448 -62
  97. models/query.events.json +216 -0
  98. models/query.eventsFeed.json +48 -0
  99. models/query.eventsTimeSeries.json +144 -0
  100. models/query.hardware.json +224 -0
  101. models/query.policy.antiMalwareFileHash.policy.json +1583 -0
  102. models/query.site.siteGeneralDetails.json +899 -0
  103. schema/catolib.py +51 -4
  104. {catocli-2.1.3.dist-info → catocli-2.1.6.dist-info}/WHEEL +0 -0
  105. {catocli-2.1.3.dist-info → catocli-2.1.6.dist-info}/entry_points.txt +0 -0
  106. {catocli-2.1.3.dist-info → catocli-2.1.6.dist-info}/licenses/LICENSE +0 -0
  107. {catocli-2.1.3.dist-info → catocli-2.1.6.dist-info}/top_level.txt +0 -0
@@ -2,12 +2,57 @@ import os
2
2
  import json
3
3
  import traceback
4
4
  import sys
5
+ import ipaddress
6
+ import csv
5
7
  from datetime import datetime
6
8
  from graphql_client.api.call_api import ApiClient, CallApi
7
9
  from graphql_client.api_client import ApiException
8
10
  from ..customLib import writeDataToFile, makeCall, getAccountID
9
11
  from ....Utils.cliutils import load_cli_settings
10
12
 
13
+ def calculateLocalIp(subnet):
14
+ """
15
+ Calculate the first usable IP address from a subnet/CIDR notation.
16
+ Returns the network address + 1 (first host IP).
17
+
18
+ Args:
19
+ subnet (str): Subnet in CIDR notation (e.g., "192.168.1.0/24")
20
+
21
+ Returns:
22
+ str: First usable IP address, or None if invalid subnet
23
+ """
24
+ if not subnet or subnet == "":
25
+ return None
26
+
27
+ try:
28
+ # Parse the subnet
29
+ network = ipaddress.IPv4Network(subnet, strict=False)
30
+
31
+ # Get the first usable IP (network address + 1)
32
+ # For /31 and /32 networks, return the network address itself
33
+ if network.prefixlen >= 31:
34
+ return str(network.network_address)
35
+ else:
36
+ # Return network + 1 (first host address)
37
+ first_host = network.network_address + 1
38
+ return str(first_host)
39
+
40
+ except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError) as e:
41
+ # Invalid subnet format
42
+ return None
43
+
44
+ def export_socket_sites_dispatcher(args, configuration):
45
+ """
46
+ Dispatcher function that routes to JSON or CSV export based on format argument
47
+ """
48
+ export_format = getattr(args, 'export_format', 'json')
49
+
50
+ if export_format == 'csv':
51
+ return export_socket_site_to_csv(args, configuration)
52
+ else:
53
+ return export_socket_site_to_json(args, configuration)
54
+
55
+
11
56
  def export_socket_site_to_json(args, configuration):
12
57
  """
13
58
  Export consolidated site and socket data to JSON format
@@ -23,7 +68,8 @@ def export_socket_site_to_json(args, configuration):
23
68
  try:
24
69
  # Load CLI settings using the robust function
25
70
  settings = load_cli_settings()
26
-
71
+ # Note: load_cli_settings() now returns embedded defaults if file cannot be loaded
72
+
27
73
  account_id = getAccountID(args, configuration)
28
74
  # Get account snapshot with siteIDs if provided
29
75
  # Get siteIDs from args if provided (comma-separated string)
@@ -38,6 +84,26 @@ def export_socket_site_to_json(args, configuration):
38
84
  ## Call APIs to retrieve sites, interface and network ranges ##
39
85
  ###############################################################
40
86
  snapshot_sites = getAccountSnapshot(args, configuration, account_id, site_ids)
87
+
88
+ # Check if no sites were found and handle gracefully
89
+ sites_list = snapshot_sites['data']['accountSnapshot']['sites']
90
+ if not sites_list or len(sites_list) == 0:
91
+ if site_ids:
92
+ # User provided specific site IDs but none were found
93
+ print(f"No sites found matching the provided site IDs: {', '.join(site_ids)}")
94
+ print("Please verify the site IDs are correct and that they exist in this account.")
95
+ return [{"success": False, "message": f"No sites found for the specified site IDs: {', '.join(site_ids)}", "sites_requested": site_ids}]
96
+ else:
97
+ # No site filter was provided but no sites exist at all
98
+ print("No sites found in this account.")
99
+ return [{"success": False, "message": "No sites found in account", "account_id": account_id}]
100
+
101
+ if hasattr(args, 'verbose') and args.verbose:
102
+ if site_ids:
103
+ print(f"Found {len(sites_list)} site(s) matching the provided site IDs")
104
+ else:
105
+ print(f"Found {len(sites_list)} site(s) in account")
106
+
41
107
  entity_network_interfaces = getEntityLookup(args, configuration, account_id, "networkInterface")
42
108
  entity_network_ranges = getEntityLookup(args, configuration, account_id, "siteRange")
43
109
  entity_sites = getEntityLookup(args, configuration, account_id, "site")
@@ -48,6 +114,7 @@ def export_socket_site_to_json(args, configuration):
48
114
  for snapshot_site in snapshot_sites['data']['accountSnapshot']['sites']:
49
115
  site_id = snapshot_site.get('id')
50
116
  connectionType = snapshot_site.get('infoSiteSnapshot', {}).get('connType', "")
117
+ # # Placeholder code to rename what the API returns if export should support cloud deployments
51
118
  # if connectionType=="VSOCKET_VGX_AWS":
52
119
  # connectionType = "SOCKET_AWS1500"
53
120
  # elif connectionType=="VSOCKET_VGX_AZURE":
@@ -81,11 +148,19 @@ def export_socket_site_to_json(args, configuration):
81
148
  cur_wan_interface['id'] = site_id+":"+ wan_ni.get('id', "")
82
149
  else:
83
150
  cur_wan_interface['id'] = site_id+":INT_"+ wan_ni.get('id', "")
151
+ # Format WAN interface index: INT_X for numeric values, keep as-is for non-numeric
152
+ wan_interface_id = wan_ni.get('id', "")
153
+ if isinstance(wan_interface_id, (int, str)) and str(wan_interface_id).isdigit():
154
+ cur_wan_interface['index'] = f"INT_{wan_interface_id}"
155
+ else:
156
+ cur_wan_interface['index'] = wan_interface_id
84
157
  cur_wan_interface['name'] = wan_ni.get('name', "")
85
158
  cur_wan_interface['upstream_bandwidth'] = wan_ni.get('upstreamBandwidth', 0)
86
159
  cur_wan_interface['downstream_bandwidth'] = wan_ni.get('downstreamBandwidth', 0)
87
160
  cur_wan_interface['dest_type'] = wan_ni.get('destType', "")
88
161
  cur_wan_interface['role'] = role
162
+ # Not supported via API to be populated later when available
163
+ cur_wan_interface['precedence'] = "ACTIVE"
89
164
  cur_site['wan_interfaces'].append(cur_wan_interface)
90
165
 
91
166
  if site_id:
@@ -109,21 +184,27 @@ def export_socket_site_to_json(args, configuration):
109
184
  lan_ni_subnet = str(lan_ni_helper_fields.get('subnet', ""))
110
185
  ni_index = lan_ni_helper_fields.get('interfaceId', "")
111
186
  ni_index = f"INT_{ni_index}" if isinstance(ni_index, (int, str)) and str(ni_index).isdigit() else ni_index
112
- if cur_site_entry["connection_type"] in settings["default_socket_interface_map"] and ni_index in settings["default_socket_interface_map"][cur_site["connection_type"]]:
187
+ if cur_site_entry["connection_type"] in settings["default_socket_interface_map"] and ni_index in settings["default_socket_interface_map"][cur_site_entry["connection_type"]]:
113
188
  cur_native_range = cur_site_entry["native_range"]
114
189
  cur_site_entry["native_range"]["interface_id"] = ni_interface_id
115
190
  cur_site_entry["native_range"]["interface_name"] = ni_interface_name
116
191
  cur_site_entry["native_range"]["subnet"] = lan_ni_subnet
117
192
  cur_site_entry["native_range"]["index"] = ni_index
193
+ # Add entry to lan interfaces for default_lan
194
+ cur_site_entry['lan_interfaces'].append({"network_ranges": [],"default_lan":True})
118
195
  else:
119
196
  cur_lan_interface['id'] = ni_interface_id
120
197
  cur_lan_interface['name'] = ni_interface_name
121
198
  cur_lan_interface['index'] = ni_index
122
199
  cur_lan_interface['dest_type'] = lan_ni_helper_fields.get('destType', "")
200
+ # temporarily add subnet to interface to be used later to flas native range_range
201
+ cur_lan_interface['subnet'] = lan_ni_subnet
123
202
  cur_site_entry['lan_interfaces'].append(cur_lan_interface)
124
203
  else:
125
204
  if hasattr(args, 'verbose') and args.verbose:
126
- print(f"WARNING: Site {lan_ni_site_id} not found in snapshot data, skipping interface {ni_interface_name} ({id})")
205
+ ni_interface_name = lan_ni_helper_fields.get('interfaceName', "")
206
+ ni_interface_id = lan_ni_entity_data.get('id', "")
207
+ print(f"WARNING: Site {lan_ni_site_id} not found in snapshot data, skipping interface {ni_interface_name} ({ni_interface_id})")
127
208
 
128
209
  #############################################################################
129
210
  ## Process entity lookup network ranges populating by network interface id ##
@@ -135,50 +216,93 @@ def export_socket_site_to_json(args, configuration):
135
216
  nr_entity_data = range.get('entity', {})
136
217
  nr_interface_name = str(nr_helper_fields.get('interfaceName', ""))
137
218
  nr_site_id = str(nr_helper_fields.get('siteId', ""))
219
+ range_id = nr_entity_data.get('id', "")
220
+
138
221
  nr_site_entry = next((site for site in processed_data['sites'] if site['id'] == nr_site_id), None)
139
222
  if nr_site_entry:
140
- nr_subnet = nr_helper_fields.get('subnet', "")
141
- nr_vlan = nr_helper_fields.get('vlanTag', "")
223
+ nr_subnet = nr_helper_fields.get('subnet', None)
224
+ nr_vlan = nr_helper_fields.get('vlanTag', None)
142
225
  nr_mdns_reflector = nr_helper_fields.get('mdnsReflector', False)
143
226
  nr_dhcp_microsegmentation = nr_helper_fields.get('microsegmentation', False)
144
- range_name = nr_entity_data.get('name', "")
227
+ nr_interface_name = str(nr_helper_fields.get('interfaceName', ""))
228
+ range_name = nr_entity_data.get('name', nr_interface_name)
145
229
  if range_name and " \\ " in range_name:
146
230
  range_name = range_name.split(" \\ ").pop()
147
231
  range_id = nr_entity_data.get('id', "")
148
- nr_interface_name = str(nr_helper_fields.get('interfaceName', ""))
149
232
 
150
233
  # the following fields are missing from the schema, populating blank fields in the interim
151
- nr_dhcp_type = nr_helper_fields.get('XXXXX', "")
152
- nr_ip_range = nr_helper_fields.get('XXXXX', "")
153
- nr_relay_group_id = nr_helper_fields.get('XXXXX', "")
154
- nr_gateway = nr_helper_fields.get('XXXXX', "")
155
- nr_range_type = nr_helper_fields.get('XXXXX', "")
156
- nr_translated_subnet = nr_helper_fields.get('XXXXX', "")
157
- nr_internet_only = nr_helper_fields.get('XXXXX', False)
158
- nr_local_ip = nr_helper_fields.get('XXXXX', "")
234
+ nr_dhcp_type = nr_helper_fields.get('XXXXX', "DHCP_DISABLED")
235
+ nr_ip_range = nr_helper_fields.get('XXXXX', None)
236
+ # nr_relay_group_id = nr_helper_fields.get('XXXXX', None)
237
+ nr_relay_group_name = nr_helper_fields.get('XXXXX', None)
238
+ nr_gateway = nr_helper_fields.get('XXXXX', None)
239
+ nr_translated_subnet = nr_helper_fields.get('XXXXX', None)
240
+ # nr_internet_only = nr_helper_fields.get('XXXXX', None)
241
+ nr_local_ip = nr_helper_fields.get('XXXXX', None)
242
+ nr_range_type = nr_helper_fields.get('XXXXX', None)
243
+ # Adding logic to pre-populate with default value
244
+ if nr_vlan!=None:
245
+ nr_range_type="VLAN"
246
+ else:
247
+ nr_range_type="Direct"
248
+
249
+ # Calculate local IP from subnet if --calculate-local-ip flag is set
250
+ if hasattr(args, 'calculate_local_ip') and args.calculate_local_ip and nr_subnet:
251
+ calculated_ip = calculateLocalIp(nr_subnet)
252
+ if calculated_ip:
253
+ nr_local_ip = calculated_ip
254
+ if hasattr(args, 'verbose') and args.verbose:
255
+ print(f" Calculated local IP for subnet {nr_subnet}: {calculated_ip}")
159
256
 
160
257
  site_native_range = nr_site_entry.get('native_range', {}) if nr_site_entry else {}
161
258
 
162
259
  if site_native_range.get("interface_name", "") == nr_interface_name:
163
- site_native_range['range_name'] = range_name
164
- site_native_range['range_id'] = range_id
165
- site_native_range['vlan'] = nr_vlan
166
- site_native_range['mdns_reflector'] = nr_mdns_reflector
167
- site_native_range['dhcp_microsegmentation'] = nr_dhcp_microsegmentation
168
- site_native_range['gateway'] = nr_gateway
169
- site_native_range['range_type'] = nr_range_type
170
- site_native_range['translated_subnet'] = nr_translated_subnet
171
- site_native_range['internet_only'] = nr_internet_only
172
- site_native_range['local_ip'] = nr_local_ip
173
- site_native_range['dhcp_settings'] = {
174
- 'dhcp_type': nr_dhcp_type,
175
- 'ip_range': nr_ip_range,
176
- 'relay_group_id': nr_relay_group_id,
177
- 'dhcp_microsegmentation': nr_dhcp_microsegmentation
178
- }
260
+ if range_name!="Native Range":
261
+ nr_lan_interface_entry = next((lan_nic for lan_nic in nr_site_entry["lan_interfaces"] if 'default_lan' in lan_nic and lan_nic['default_lan']), None)
262
+ # print(f"checking range: {network_range_site_id} - {network_range_interface_name}")
263
+ if nr_lan_interface_entry:
264
+ cur_range = {}
265
+ cur_range['id'] = range_id
266
+ cur_range['name'] = range_name
267
+ cur_range['subnet'] = nr_subnet
268
+ cur_range['vlan'] = nr_vlan
269
+ cur_range['mdns_reflector'] = nr_mdns_reflector
270
+ ## The folliowing fields are missing from the schema, populating blank fields in the interim
271
+ cur_range['gateway'] = nr_gateway
272
+ cur_range['range_type'] = nr_range_type
273
+ cur_range['translated_subnet'] = nr_translated_subnet
274
+ # # Not available to set for native_range via API today
275
+ # cur_range['internet_only'] = nr_internet_only
276
+ cur_range['local_ip'] = nr_local_ip # Use the calculated or original value
277
+ cur_range['dhcp_settings'] = {
278
+ 'dhcp_type': nr_dhcp_type,
279
+ 'ip_range': nr_ip_range,
280
+ 'relay_group_id': None,
281
+ 'relay_group_name': nr_relay_group_name,
282
+ 'dhcp_microsegmentation': nr_dhcp_microsegmentation
283
+ }
284
+ nr_lan_interface_entry["network_ranges"].append(cur_range)
285
+ else:
286
+ site_native_range['range_name'] = range_name
287
+ site_native_range['range_id'] = range_id
288
+ site_native_range['vlan'] = nr_vlan
289
+ site_native_range['mdns_reflector'] = nr_mdns_reflector
290
+ # site_native_range['dhcp_microsegmentation'] = nr_dhcp_microsegmentation
291
+ site_native_range['gateway'] = nr_gateway
292
+ site_native_range['range_type'] = nr_range_type
293
+ site_native_range['translated_subnet'] = nr_translated_subnet
294
+ # # Not available to set for native_range via API today
295
+ # site_native_range['internet_only'] = nr_internet_only
296
+ site_native_range['local_ip'] = nr_local_ip
297
+ site_native_range['dhcp_settings'] = {
298
+ 'dhcp_type': nr_dhcp_type,
299
+ 'ip_range': nr_ip_range,
300
+ 'relay_group_id': None,
301
+ 'relay_group_name': nr_relay_group_name,
302
+ 'dhcp_microsegmentation': nr_dhcp_microsegmentation
303
+ }
179
304
  else:
180
- nr_lan_interface_entry = next((lan_nic for lan_nic in nr_site_entry["lan_interfaces"] if lan_nic['name'] == nr_interface_name), None)
181
- # print(f"checking range: {network_range_site_id} - {network_range_interface_name}")
305
+ nr_lan_interface_entry = next((lan_nic for lan_nic in nr_site_entry["lan_interfaces"] if ('default_lan' not in lan_nic or not lan_nic['default_lan']) and lan_nic['name'] == nr_interface_name), None)
182
306
  if nr_lan_interface_entry:
183
307
  cur_range = {}
184
308
  cur_range['id'] = range_id
@@ -187,30 +311,62 @@ def export_socket_site_to_json(args, configuration):
187
311
  cur_range['vlan'] = nr_vlan
188
312
  cur_range['mdns_reflector'] = nr_mdns_reflector
189
313
  ## The folliowing fields are missing from the schema, populating blank fields in the interim
190
- cur_range['gateway'] = nr_helper_fields.get('XXXXX', "")
191
- cur_range['range_type'] = nr_helper_fields.get('XXXXX', "")
192
- cur_range['translated_subnet'] = nr_helper_fields.get('XXXXX', "")
193
- cur_range['internet_only'] = nr_helper_fields.get('XXXXX', "False")
194
- cur_range['local_ip'] = nr_helper_fields.get('XXXXX', "")
314
+ cur_range['gateway'] = nr_gateway
315
+ cur_range['range_type'] = nr_range_type
316
+ cur_range['translated_subnet'] = nr_translated_subnet
317
+ # # Not available to set for native_range via API today
318
+ # cur_range['internet_only'] = nr_internet_only
319
+ cur_range['local_ip'] = nr_local_ip # Use the calculated or original value
195
320
  cur_range['dhcp_settings'] = {
196
- 'dhcp_type': nr_helper_fields.get('XXXXX', ""),
197
- 'ip_range': nr_helper_fields.get('XXXXX', ""),
198
- 'relay_group_id': nr_helper_fields.get('XXXXX', ""),
321
+ 'dhcp_type': nr_dhcp_type,
322
+ 'ip_range': nr_ip_range,
323
+ 'relay_group_id': None,
324
+ 'relay_group_name': nr_relay_group_name,
199
325
  'dhcp_microsegmentation': nr_dhcp_microsegmentation
200
326
  }
327
+ # DEBUG
328
+ # print(json.dumps(nr_lan_interface_entry,indent=4,sort_keys=True))
329
+ # print("nr_subnet",nr_subnet)
330
+ # print('nr_lan_interface_entry["subnet"]='+nr_lan_interface_entry["subnet"])
331
+ # print(json.dumps(nr_lan_interface_entry,indent=4,sort_keys=True))
332
+ if "subnet" in nr_lan_interface_entry and nr_subnet==nr_lan_interface_entry["subnet"]:
333
+ cur_range['native_range'] = True
334
+ del nr_lan_interface_entry["subnet"]
335
+
201
336
  nr_lan_interface_entry["network_ranges"].append(cur_range)
202
337
  else:
203
- # if hasattr(args, 'verbose') and args.verbose:
204
- print(f"Skipping range {nr_entity_data.get('id', '')}: site_id {nr_site_id} and {nr_interface_name} not found in ")
338
+ if hasattr(args, 'verbose') and args.verbose:
339
+ print(f"Skipping range {nr_entity_data.get('id', '')}: site_id {nr_site_id} and {nr_interface_name} not found in ")
205
340
  else:
206
341
  if hasattr(args, 'verbose') and args.verbose:
207
342
  print(f"Skipping range, site_id is unsupported for export {nr_site_id}")
208
343
 
209
- # Handle timestamp in filename if requested
210
- filename_template = "socket_sites_{account_id}.json"
211
- if hasattr(args, 'append_timestamp') and args.append_timestamp:
212
- timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
213
- filename_template = "socket_sites_{account_id}_" + timestamp + ".json"
344
+ # Handle custom filename and timestamp
345
+ if hasattr(args, 'json_filename') and args.json_filename:
346
+ # User provided custom filename
347
+ base_filename = args.json_filename
348
+ # Remove .json extension if provided, we'll add it back
349
+ if base_filename.endswith('.json'):
350
+ base_filename = base_filename[:-5]
351
+
352
+ if hasattr(args, 'append_timestamp') and args.append_timestamp:
353
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
354
+ filename_template = f"{base_filename}_{timestamp}.json"
355
+ else:
356
+ filename_template = f"{base_filename}.json"
357
+ else:
358
+ # Use default filename template
359
+ if hasattr(args, 'append_timestamp') and args.append_timestamp:
360
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
361
+ filename_template = f"socket_sites_{{account_id}}_{timestamp}.json"
362
+ else:
363
+ filename_template = "socket_sites_{account_id}.json"
364
+
365
+ if hasattr(args, 'verbose') and args.verbose:
366
+ if hasattr(args, 'json_filename') and args.json_filename:
367
+ print(f"Using custom filename template: {filename_template}")
368
+ else:
369
+ print(f"Using default filename template: {filename_template}")
214
370
 
215
371
  # Write the processed data to file using the general-purpose function
216
372
  output_file = writeDataToFile(
@@ -253,6 +409,617 @@ def export_socket_site_to_json(args, configuration):
253
409
  return [{"success": False, "error": str(e), "error_details": error_details}]
254
410
 
255
411
 
412
+ def export_socket_site_to_csv(args, configuration):
413
+ """
414
+ Export consolidated site and socket data to CSV format
415
+ Creates main sites CSV and individual network ranges CSV files
416
+ """
417
+ try:
418
+ # First get the JSON data using existing function
419
+ json_result = export_socket_site_to_json(args, configuration)
420
+
421
+ if not json_result or not json_result[0].get('success'):
422
+ return json_result
423
+
424
+ # Load the processed data from the JSON function
425
+ # We'll re-use the data processing logic but output to CSV instead
426
+ processed_data = get_processed_site_data(args, configuration)
427
+
428
+ if not processed_data or not processed_data.get('sites'):
429
+ return [{"success": False, "error": "No sites data found to export"}]
430
+
431
+ account_id = getAccountID(args, configuration)
432
+ output_files = []
433
+
434
+ # Export main sites CSV
435
+ sites_csv_file = export_sites_to_csv(processed_data['sites'], args, account_id)
436
+ output_files.append(sites_csv_file)
437
+
438
+ # Export individual network ranges CSV files for each site
439
+ for site in processed_data['sites']:
440
+ ranges_csv_file = export_network_ranges_to_csv(site, args, account_id)
441
+ if ranges_csv_file:
442
+ output_files.append(ranges_csv_file)
443
+
444
+ return [{"success": True, "output_files": output_files, "account_id": account_id}]
445
+
446
+ except Exception as e:
447
+ exc_type, exc_value, exc_traceback = sys.exc_info()
448
+ line_number = exc_traceback.tb_lineno
449
+ filename = exc_traceback.tb_frame.f_code.co_filename
450
+ function_name = exc_traceback.tb_frame.f_code.co_name
451
+ full_traceback = traceback.format_exc()
452
+
453
+ error_details = {
454
+ "error_type": exc_type.__name__,
455
+ "error_message": str(exc_value),
456
+ "line_number": line_number,
457
+ "function_name": function_name,
458
+ "filename": os.path.basename(filename),
459
+ "full_traceback": full_traceback
460
+ }
461
+
462
+ print(f"ERROR: {exc_type.__name__}: {str(exc_value)}")
463
+ print(f"Location: {os.path.basename(filename)}:{line_number} in {function_name}()")
464
+ print(f"Full traceback:\n{full_traceback}")
465
+
466
+ return [{"success": False, "error": str(e), "error_details": error_details}]
467
+
468
+
469
+ def get_processed_site_data(args, configuration):
470
+ """
471
+ Get processed site data without writing to JSON file
472
+ Reuses the logic from export_socket_site_to_json but returns data instead
473
+ """
474
+ processed_data = {'sites': []}
475
+
476
+ # Load CLI settings
477
+ settings = load_cli_settings()
478
+ # Note: load_cli_settings() now returns embedded defaults if file cannot be loaded
479
+
480
+ account_id = getAccountID(args, configuration)
481
+
482
+ # Get siteIDs from args if provided
483
+ site_ids = []
484
+ if hasattr(args, 'siteIDs') and args.siteIDs:
485
+ site_ids = [site_id.strip() for site_id in args.siteIDs.split(',') if site_id.strip()]
486
+
487
+ # Call APIs to retrieve sites, interface and network ranges
488
+ snapshot_sites = getAccountSnapshot(args, configuration, account_id, site_ids)
489
+ sites_list = snapshot_sites['data']['accountSnapshot']['sites']
490
+
491
+ if not sites_list or len(sites_list) == 0:
492
+ return processed_data
493
+
494
+ entity_network_interfaces = getEntityLookup(args, configuration, account_id, "networkInterface")
495
+ entity_network_ranges = getEntityLookup(args, configuration, account_id, "siteRange")
496
+ entity_sites = getEntityLookup(args, configuration, account_id, "site")
497
+
498
+ # Process sites (reuse existing logic)
499
+ for snapshot_site in snapshot_sites['data']['accountSnapshot']['sites']:
500
+ site_id = snapshot_site.get('id')
501
+ connectionType = snapshot_site.get('infoSiteSnapshot', {}).get('connType', "")
502
+
503
+ cur_site = {
504
+ 'wan_interfaces': [],
505
+ 'lan_interfaces': [],
506
+ 'native_range': {}
507
+ }
508
+
509
+ if connectionType in settings["export_by_socket_type"]:
510
+ cur_site['id'] = site_id
511
+ cur_site['name'] = snapshot_site.get('infoSiteSnapshot', {}).get('name')
512
+ cur_site['description'] = snapshot_site.get('infoSiteSnapshot', {}).get('description')
513
+ cur_site['connection_type'] = connectionType
514
+ cur_site['type'] = snapshot_site.get('infoSiteSnapshot', {}).get('type')
515
+ cur_site = populateSiteLocationData(args, snapshot_site, cur_site)
516
+
517
+ # Process WAN interfaces
518
+ site_interfaces = snapshot_site.get('infoSiteSnapshot', {}).get('interfaces', [])
519
+ for wan_ni in site_interfaces:
520
+ cur_wan_interface = {}
521
+ role = wan_ni.get('wanRoleInterfaceInfo', "")
522
+ interfaceName = wan_ni.get('id', "")
523
+ if role is not None and role[0:3] == "wan":
524
+ if interfaceName[0:3] in ("WAN", "USB", "LTE"):
525
+ cur_wan_interface['id'] = site_id+":"+ wan_ni.get('id', "")
526
+ else:
527
+ cur_wan_interface['id'] = site_id+":INT_"+ wan_ni.get('id', "")
528
+ # Format WAN interface index: INT_X for numeric values, keep as-is for non-numeric
529
+ wan_interface_id = wan_ni.get('id', "")
530
+ if isinstance(wan_interface_id, (int, str)) and str(wan_interface_id).isdigit():
531
+ cur_wan_interface['index'] = f"INT_{wan_interface_id}"
532
+ else:
533
+ cur_wan_interface['index'] = wan_interface_id
534
+ cur_wan_interface['name'] = wan_ni.get('name', "")
535
+ cur_wan_interface['upstream_bandwidth'] = wan_ni.get('upstreamBandwidth', 0)
536
+ cur_wan_interface['downstream_bandwidth'] = wan_ni.get('downstreamBandwidth', 0)
537
+ cur_wan_interface['dest_type'] = wan_ni.get('destType', "")
538
+ cur_wan_interface['role'] = role
539
+ cur_wan_interface['precedence'] = "ACTIVE"
540
+ cur_site['wan_interfaces'].append(cur_wan_interface)
541
+
542
+ if site_id:
543
+ processed_data['sites'].append(cur_site)
544
+
545
+ # Process LAN interfaces (reuse existing logic)
546
+ for lan_ni in entity_network_interfaces:
547
+ lan_ni_helper_fields = lan_ni.get("helperFields", {})
548
+ lan_ni_entity_data = lan_ni.get('entity', {})
549
+ lan_ni_site_id = str(lan_ni_helper_fields.get('siteId', ""))
550
+ cur_site_entry = next((site for site in processed_data['sites'] if site['id'] == lan_ni_site_id), None)
551
+ if cur_site_entry:
552
+ cur_lan_interface = {'network_ranges': []}
553
+ ni_interface_id = lan_ni_entity_data.get('id', "")
554
+ ni_interface_name = lan_ni_helper_fields.get('interfaceName', "")
555
+ lan_ni_subnet = str(lan_ni_helper_fields.get('subnet', ""))
556
+ ni_index = lan_ni_helper_fields.get('interfaceId', "")
557
+ ni_index = f"INT_{ni_index}" if isinstance(ni_index, (int, str)) and str(ni_index).isdigit() else ni_index
558
+
559
+ if cur_site_entry["connection_type"] in settings["default_socket_interface_map"] and ni_index in settings["default_socket_interface_map"][cur_site_entry["connection_type"]]:
560
+ cur_site_entry["native_range"]["interface_id"] = ni_interface_id
561
+ cur_site_entry["native_range"]["interface_name"] = ni_interface_name
562
+ cur_site_entry["native_range"]["subnet"] = lan_ni_subnet
563
+ cur_site_entry["native_range"]["index"] = ni_index
564
+ cur_site_entry['lan_interfaces'].append({"network_ranges": [], "default_lan": True})
565
+ else:
566
+ cur_lan_interface['id'] = ni_interface_id
567
+ cur_lan_interface['name'] = ni_interface_name
568
+ cur_lan_interface['index'] = ni_index
569
+ cur_lan_interface['dest_type'] = lan_ni_helper_fields.get('destType', "")
570
+ cur_lan_interface['subnet'] = lan_ni_subnet
571
+ cur_site_entry['lan_interfaces'].append(cur_lan_interface)
572
+
573
+ # Process network ranges (reuse existing logic)
574
+ for range in entity_network_ranges:
575
+ nr_helper_fields = range.get("helperFields", {})
576
+ nr_entity_data = range.get('entity', {})
577
+ nr_interface_name = str(nr_helper_fields.get('interfaceName', ""))
578
+ nr_site_id = str(nr_helper_fields.get('siteId', ""))
579
+ range_id = nr_entity_data.get('id', "")
580
+
581
+ nr_site_entry = next((site for site in processed_data['sites'] if site['id'] == nr_site_id), None)
582
+ if nr_site_entry:
583
+ nr_subnet = nr_helper_fields.get('subnet', None)
584
+ nr_vlan = nr_helper_fields.get('vlanTag', None)
585
+ nr_mdns_reflector = nr_helper_fields.get('mdnsReflector', False)
586
+ nr_dhcp_microsegmentation = nr_helper_fields.get('microsegmentation', False)
587
+ range_name = nr_entity_data.get('name', nr_interface_name)
588
+ if range_name and " \\ " in range_name:
589
+ range_name = range_name.split(" \\ ").pop()
590
+
591
+ # Set defaults for missing fields
592
+ nr_dhcp_type = "DHCP_DISABLED"
593
+ nr_ip_range = None
594
+ nr_relay_group_name = None
595
+ nr_gateway = None
596
+ nr_translated_subnet = None
597
+ nr_local_ip = None
598
+ nr_range_type = "VLAN" if nr_vlan != None else "Direct"
599
+
600
+ # Calculate local IP if requested
601
+ if hasattr(args, 'calculate_local_ip') and args.calculate_local_ip and nr_subnet:
602
+ calculated_ip = calculateLocalIp(nr_subnet)
603
+ if calculated_ip:
604
+ nr_local_ip = calculated_ip
605
+
606
+ site_native_range = nr_site_entry.get('native_range', {})
607
+
608
+ if site_native_range.get("interface_name", "") == nr_interface_name:
609
+ if range_name != "Native Range":
610
+ nr_lan_interface_entry = next((lan_nic for lan_nic in nr_site_entry["lan_interfaces"] if 'default_lan' in lan_nic and lan_nic['default_lan']), None)
611
+ if nr_lan_interface_entry:
612
+ cur_range = {
613
+ 'id': range_id, 'name': range_name, 'subnet': nr_subnet, 'vlan': nr_vlan,
614
+ 'mdns_reflector': nr_mdns_reflector, 'gateway': nr_gateway, 'range_type': nr_range_type,
615
+ 'translated_subnet': nr_translated_subnet, 'local_ip': nr_local_ip,
616
+ 'dhcp_settings': {
617
+ 'dhcp_type': nr_dhcp_type, 'ip_range': nr_ip_range, 'relay_group_id': None,
618
+ 'relay_group_name': nr_relay_group_name, 'dhcp_microsegmentation': nr_dhcp_microsegmentation
619
+ }
620
+ }
621
+ nr_lan_interface_entry["network_ranges"].append(cur_range)
622
+ else:
623
+ site_native_range['range_name'] = range_name
624
+ site_native_range['range_id'] = range_id
625
+ site_native_range['vlan'] = nr_vlan
626
+ site_native_range['mdns_reflector'] = nr_mdns_reflector
627
+ site_native_range['gateway'] = nr_gateway
628
+ site_native_range['range_type'] = nr_range_type
629
+ site_native_range['translated_subnet'] = nr_translated_subnet
630
+ site_native_range['local_ip'] = nr_local_ip
631
+ site_native_range['dhcp_settings'] = {
632
+ 'dhcp_type': nr_dhcp_type, 'ip_range': nr_ip_range, 'relay_group_id': None,
633
+ 'relay_group_name': nr_relay_group_name, 'dhcp_microsegmentation': nr_dhcp_microsegmentation
634
+ }
635
+ else:
636
+ nr_lan_interface_entry = next((lan_nic for lan_nic in nr_site_entry["lan_interfaces"] if ('default_lan' not in lan_nic or not lan_nic['default_lan']) and lan_nic['name'] == nr_interface_name), None)
637
+ if nr_lan_interface_entry:
638
+ cur_range = {
639
+ 'id': range_id, 'name': range_name, 'subnet': nr_subnet, 'vlan': nr_vlan,
640
+ 'mdns_reflector': nr_mdns_reflector, 'gateway': nr_gateway, 'range_type': nr_range_type,
641
+ 'translated_subnet': nr_translated_subnet, 'local_ip': nr_local_ip,
642
+ 'dhcp_settings': {
643
+ 'dhcp_type': nr_dhcp_type, 'ip_range': nr_ip_range, 'relay_group_id': None,
644
+ 'relay_group_name': nr_relay_group_name, 'dhcp_microsegmentation': nr_dhcp_microsegmentation
645
+ }
646
+ }
647
+
648
+ if "subnet" in nr_lan_interface_entry and nr_subnet == nr_lan_interface_entry["subnet"]:
649
+ cur_range['native_range'] = True
650
+ del nr_lan_interface_entry["subnet"]
651
+
652
+ nr_lan_interface_entry["network_ranges"].append(cur_range)
653
+
654
+ return processed_data
655
+
656
+
657
+ def export_sites_to_csv(sites, args, account_id):
658
+ """
659
+ Export main sites data to CSV file in bulk-sites format
660
+ One row per WAN interface, site attributes only on first row per site
661
+ """
662
+ # Handle custom filename and timestamp
663
+ if hasattr(args, 'csv_filename') and args.csv_filename:
664
+ base_filename = args.csv_filename
665
+ if base_filename.endswith('.csv'):
666
+ base_filename = base_filename[:-4]
667
+
668
+ if hasattr(args, 'append_timestamp') and args.append_timestamp:
669
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
670
+ filename_template = f"{base_filename}_{timestamp}.csv"
671
+ else:
672
+ filename_template = f"{base_filename}.csv"
673
+ else:
674
+ if hasattr(args, 'append_timestamp') and args.append_timestamp:
675
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
676
+ filename_template = f"socket_sites_{{account_id}}_{timestamp}.csv"
677
+ else:
678
+ filename_template = "socket_sites_{account_id}.csv"
679
+
680
+ # Replace account_id placeholder
681
+ filename = filename_template.format(account_id=account_id)
682
+
683
+ # Determine output directory
684
+ output_dir = getattr(args, 'output_directory', None)
685
+ if not output_dir:
686
+ output_dir = 'config_data'
687
+ if not os.path.isabs(output_dir):
688
+ output_dir = os.path.join(os.getcwd(), output_dir)
689
+
690
+ os.makedirs(output_dir, exist_ok=True)
691
+ filepath = os.path.join(output_dir, filename)
692
+
693
+ # Define CSV headers matching bulk-sites.csv format
694
+ headers = [
695
+ 'site_id',
696
+ 'site_name',
697
+ 'wan_interface_id',
698
+ 'wan_interface_index',
699
+ 'wan_interface_name',
700
+ 'wan_upstream_bw',
701
+ 'wan_downstream_bw',
702
+ 'wan_role',
703
+ 'wan_precedence',
704
+ 'site_description',
705
+ 'native_range_subnet',
706
+ 'native_range_name',
707
+ 'native_range_id',
708
+ 'native_range_vlan',
709
+ 'native_range_mdns_reflector',
710
+ 'native_range_gateway',
711
+ 'native_range_type',
712
+ 'native_range_translated_subnet',
713
+ 'native_range_interface_id',
714
+ 'native_range_interface_index',
715
+ 'native_range_interface_name',
716
+ 'native_range_dhcp_type',
717
+ 'native_range_dhcp_ip_range',
718
+ 'native_range_dhcp_relay_group_id',
719
+ 'native_range_dhcp_relay_group_name',
720
+ 'native_range_dhcp_microsegmentation',
721
+ 'native_range_local_ip',
722
+ 'site_type',
723
+ 'connection_type',
724
+ 'site_location_address',
725
+ 'site_location_city',
726
+ 'site_location_country_code',
727
+ 'site_location_state_code',
728
+ 'site_location_timezone'
729
+ ]
730
+
731
+ rows = []
732
+ total_interfaces = 0
733
+
734
+ for site in sites:
735
+ site_name = site.get('name', '')
736
+ wan_interfaces = site.get('wan_interfaces', [])
737
+
738
+ # Sort WAN interfaces to ensure 'wan_1' role appears first
739
+ # This organizes the CSV so wan_1 interfaces are on the first line for each site
740
+ def wan_interface_sort_key(interface):
741
+ role = interface.get('role', '').lower()
742
+ if role == 'wan_1':
743
+ return 0 # wan_1 comes first
744
+ elif role == 'wan_2':
745
+ return 1
746
+ elif role == 'wan_3':
747
+ return 2
748
+ elif role == 'wan_4':
749
+ return 3
750
+ else:
751
+ return 9 # Unknown/other roles come last
752
+
753
+ wan_interfaces.sort(key=wan_interface_sort_key)
754
+
755
+ # If no WAN interfaces, create one empty row for the site
756
+ if not wan_interfaces:
757
+ wan_interfaces = [{}] # Empty interface to ensure site is included
758
+
759
+ for idx, wan_interface in enumerate(wan_interfaces):
760
+ # Site-specific attributes only on first row per site
761
+ is_first_interface = (idx == 0)
762
+
763
+ # Calculate local IP from native range subnet if available
764
+ native_range = site.get('native_range', {})
765
+ native_subnet = native_range.get('subnet', '')
766
+ local_ip = ''
767
+ if native_subnet and hasattr(args, 'calculate_local_ip') and args.calculate_local_ip:
768
+ calculated_ip = calculateLocalIp(native_subnet)
769
+ if calculated_ip:
770
+ local_ip = calculated_ip
771
+ elif native_range.get('local_ip'):
772
+ local_ip = native_range.get('local_ip', '')
773
+
774
+ row = {
775
+ 'site_id': site.get('id', '') if is_first_interface else '',
776
+ 'site_name': site_name,
777
+ 'wan_interface_id': wan_interface.get('id', '') if wan_interface else '',
778
+ 'wan_interface_index': wan_interface.get('index', '') if wan_interface else '',
779
+ 'wan_interface_name': wan_interface.get('name', '') if wan_interface else '',
780
+ 'wan_upstream_bw': wan_interface.get('upstream_bandwidth', '') if wan_interface else '',
781
+ 'wan_downstream_bw': wan_interface.get('downstream_bandwidth', '') if wan_interface else '',
782
+ 'wan_role': wan_interface.get('role', '') if wan_interface else '',
783
+ 'wan_precedence': wan_interface.get('precedence', '') if wan_interface else '',
784
+
785
+ # Site attributes - only populate on first interface row
786
+ 'site_description': site.get('description', '') if is_first_interface else '',
787
+ 'native_range_subnet': native_subnet if is_first_interface else '',
788
+ 'native_range_name': native_range.get('range_name', '') if is_first_interface else '',
789
+ 'native_range_id': native_range.get('range_id', '') if is_first_interface else '',
790
+ 'native_range_vlan': native_range.get('vlan', '') if is_first_interface else '',
791
+ 'native_range_mdns_reflector': str(native_range.get('mdns_reflector', '')).upper() if is_first_interface and native_range.get('mdns_reflector') != '' else '' if is_first_interface else '',
792
+ 'native_range_gateway': native_range.get('gateway', '') if is_first_interface else '',
793
+ 'native_range_type': native_range.get('range_type', '') if is_first_interface else '',
794
+ 'native_range_translated_subnet': native_range.get('translated_subnet', '') if is_first_interface else '',
795
+ 'native_range_interface_id': native_range.get('interface_id', '') if is_first_interface else '',
796
+ 'native_range_interface_index': native_range.get('index', '') if is_first_interface else '',
797
+ 'native_range_interface_name': native_range.get('interface_name', '') if is_first_interface else '',
798
+ 'native_range_dhcp_type': native_range.get('dhcp_settings', {}).get('dhcp_type', '') if is_first_interface else '',
799
+ 'native_range_dhcp_ip_range': native_range.get('dhcp_settings', {}).get('ip_range', '') if is_first_interface else '',
800
+ 'native_range_dhcp_relay_group_id': native_range.get('dhcp_settings', {}).get('relay_group_id', '') if is_first_interface else '',
801
+ 'native_range_dhcp_relay_group_name': native_range.get('dhcp_settings', {}).get('relay_group_name', '') if is_first_interface else '',
802
+ 'native_range_dhcp_microsegmentation': str(native_range.get('dhcp_settings', {}).get('dhcp_microsegmentation', '')).upper() if is_first_interface and native_range.get('dhcp_settings', {}).get('dhcp_microsegmentation') != '' else '' if is_first_interface else '',
803
+ 'native_range_local_ip': local_ip if is_first_interface else '',
804
+ 'site_type': site.get('type', '') if is_first_interface else '',
805
+ 'connection_type': site.get('connection_type', '') if is_first_interface else '',
806
+ 'site_location_address': site.get('site_location', {}).get('address', '') if is_first_interface else '',
807
+ 'site_location_city': site.get('site_location', {}).get('city', '') if is_first_interface else '',
808
+ 'site_location_country_code': site.get('site_location', {}).get('countryCode', '') if is_first_interface else '',
809
+ 'site_location_state_code': site.get('site_location', {}).get('stateCode', '') if is_first_interface else '',
810
+ 'site_location_timezone': site.get('site_location', {}).get('timezone', '') if is_first_interface else ''
811
+ }
812
+
813
+ rows.append(row)
814
+ total_interfaces += 1 if wan_interface else 0
815
+
816
+ with open(filepath, 'w', newline='', encoding='utf-8') as csvfile:
817
+ writer = csv.DictWriter(csvfile, fieldnames=headers)
818
+ writer.writeheader()
819
+ writer.writerows(rows)
820
+
821
+ if hasattr(args, 'verbose') and args.verbose:
822
+ print(f"Exported {len(sites)} sites with {total_interfaces} WAN interfaces to {filepath}")
823
+
824
+ return filepath
825
+
826
+
827
+ def export_network_ranges_to_csv(site, args, account_id):
828
+ """
829
+ Export network ranges for a single site to CSV file
830
+ Structure: LAN interface as parent with network ranges as children
831
+ First row per interface contains interface details, subsequent rows contain only network range details
832
+ """
833
+ site_name = site.get('name', '')
834
+ if not site_name:
835
+ return None
836
+
837
+ # Sanitize site name for filename
838
+ safe_site_name = "".join(c for c in site_name if c.isalnum() or c in ('-', '_')).rstrip()
839
+
840
+ # Determine output directory
841
+ output_dir = getattr(args, 'output_directory', None)
842
+ if not output_dir:
843
+ output_dir = 'config_data'
844
+ if not os.path.isabs(output_dir):
845
+ output_dir = os.path.join(os.getcwd(), output_dir)
846
+
847
+ # Create sites_config subdirectory with account ID
848
+ sites_config_dir = os.path.join(output_dir, f'sites_config_{account_id}')
849
+ os.makedirs(sites_config_dir, exist_ok=True)
850
+
851
+ filename = f"{safe_site_name}_network_ranges.csv"
852
+ filepath = os.path.join(sites_config_dir, filename)
853
+
854
+ # Define CSV headers - Reordered LAN interface columns first, then network range columns
855
+ headers = [
856
+ # LAN Interface columns (first 3 columns, is_native_range 4th, lan_interface_index 5th)
857
+ 'lan_interface_id', 'lan_interface_name', 'lan_interface_dest_type', 'is_native_range', 'lan_interface_index',
858
+ # Network Range columns (populated on all rows)
859
+ 'network_range_id', 'network_range_name', 'subnet', 'vlan', 'mdns_reflector',
860
+ 'gateway', 'range_type', 'translated_subnet', 'local_ip',
861
+ 'dhcp_type', 'dhcp_ip_range', 'dhcp_relay_group_id', 'dhcp_relay_group_name', 'dhcp_microsegmentation'
862
+ ]
863
+
864
+ rows = []
865
+
866
+ # Get the native range subnet from the site to exclude it from detailed CSV
867
+ native_range_subnet = site.get('native_range', {}).get('subnet', '')
868
+
869
+ # Process default LAN interface (from native_range) - ONLY if it has additional networks
870
+ native_range = site.get('native_range', {})
871
+ native_range_index = native_range.get('index', '') # The default interface index from parent CSV
872
+ default_lan_interfaces = [lan_nic for lan_nic in site.get('lan_interfaces', []) if lan_nic.get('default_lan', False)]
873
+
874
+ if default_lan_interfaces:
875
+ for default_lan_interface in default_lan_interfaces:
876
+ interface_id = native_range.get('interface_id', '')
877
+ interface_name = native_range.get('interface_name', '')
878
+ interface_index = native_range.get('index', '') # Use actual interface index like INT_5
879
+ interface_dest_type = 'LAN'
880
+
881
+ network_ranges = default_lan_interface.get('network_ranges', [])
882
+
883
+ # Filter out the network range that matches the parent CSV native_range_subnet
884
+ filtered_ranges = [nr for nr in network_ranges if nr.get('subnet', '') != native_range_subnet]
885
+
886
+ # For default_lan interfaces, ONLY create entries if there are additional ranges
887
+ # Do NOT create an entry with is_native_range=TRUE since native range is managed at parent level
888
+ if filtered_ranges:
889
+ # Process filtered ranges - all marked as additional (non-native) ranges
890
+ for idx, network_range in enumerate(filtered_ranges):
891
+ # First row for this interface includes interface details
892
+ is_first_range = (idx == 0)
893
+
894
+ # For default_lan interfaces, all ranges are additional (is_native_range=FALSE)
895
+ # because the native range is already defined in the parent CSV
896
+
897
+ row = {
898
+ # LAN Interface details - for default LAN interfaces, don't populate interface ID, name, type since managed at parent level
899
+ 'lan_interface_id': '', # Empty for default LAN interfaces (managed at parent level)
900
+ 'lan_interface_name': '', # Empty for default LAN interfaces (managed at parent level)
901
+ 'lan_interface_dest_type': '', # Empty for default LAN interfaces (managed at parent level)
902
+ 'is_native_range': '', # Always empty for default LAN interfaces
903
+ 'lan_interface_index': interface_index, # Populated for every row
904
+
905
+ # Network Range details (on all rows)
906
+ 'network_range_id': network_range.get('id', ''),
907
+ 'network_range_name': network_range.get('name', ''),
908
+ 'subnet': network_range.get('subnet', ''),
909
+ 'vlan': network_range.get('vlan', ''),
910
+ 'mdns_reflector': str(network_range.get('mdns_reflector', False)).upper(),
911
+ 'gateway': network_range.get('gateway', ''),
912
+ 'range_type': network_range.get('range_type', ''),
913
+ 'translated_subnet': network_range.get('translated_subnet', ''),
914
+ 'local_ip': network_range.get('local_ip', ''),
915
+ 'dhcp_type': network_range.get('dhcp_settings', {}).get('dhcp_type', ''),
916
+ 'dhcp_ip_range': network_range.get('dhcp_settings', {}).get('ip_range', ''),
917
+ 'dhcp_relay_group_id': network_range.get('dhcp_settings', {}).get('relay_group_id', ''),
918
+ 'dhcp_relay_group_name': network_range.get('dhcp_settings', {}).get('relay_group_name', ''),
919
+ 'dhcp_microsegmentation': str(network_range.get('dhcp_settings', {}).get('dhcp_microsegmentation', False)).upper()
920
+ }
921
+ rows.append(row)
922
+
923
+ # Process regular LAN interfaces and their network ranges
924
+ for lan_interface in site.get('lan_interfaces', []):
925
+ is_default_lan = lan_interface.get('default_lan', False)
926
+
927
+ # Skip default_lan interfaces (already processed above)
928
+ if is_default_lan:
929
+ continue
930
+
931
+ interface_id = lan_interface.get('id', '')
932
+ interface_name = lan_interface.get('name', '')
933
+ interface_index = lan_interface.get('index', '')
934
+ interface_dest_type = lan_interface.get('dest_type', '')
935
+
936
+ network_ranges = lan_interface.get('network_ranges', [])
937
+
938
+ # Filter out the network range that matches the parent CSV native_range_subnet
939
+ filtered_ranges = [nr for nr in network_ranges if nr.get('subnet', '') != native_range_subnet]
940
+
941
+ # If no filtered ranges, create at least one row with just the LAN interface info
942
+ # For regular LAN interfaces, mark as native range if this is the first/only interface
943
+ if not filtered_ranges:
944
+ row = {
945
+ # LAN Interface details - first 3 columns reordered, is_native_range 4th, lan_interface_index 5th
946
+ 'lan_interface_id': interface_id,
947
+ 'lan_interface_name': interface_name,
948
+ 'lan_interface_dest_type': interface_dest_type,
949
+ 'is_native_range': 'TRUE', # Mark as native range for non-default LAN interfaces with no additional ranges
950
+ 'lan_interface_index': interface_index,
951
+
952
+ # Network Range details (empty since no additional ranges)
953
+ 'network_range_id': '',
954
+ 'network_range_name': '',
955
+ 'subnet': '',
956
+ 'vlan': '',
957
+ 'mdns_reflector': '',
958
+ 'gateway': '',
959
+ 'range_type': '',
960
+ 'translated_subnet': '',
961
+ 'local_ip': '',
962
+ 'dhcp_type': '',
963
+ 'dhcp_ip_range': '',
964
+ 'dhcp_relay_group_id': '',
965
+ 'dhcp_relay_group_name': '',
966
+ 'dhcp_microsegmentation': ''
967
+ }
968
+ rows.append(row)
969
+ else:
970
+ # Process filtered ranges as before
971
+ for idx, network_range in enumerate(filtered_ranges):
972
+ # First row for this interface includes interface details
973
+ is_first_range = (idx == 0)
974
+
975
+ # For non-default_lan interfaces, first range should be marked as is_native_range
976
+ is_native_range = is_first_range
977
+
978
+ row = {
979
+ # LAN Interface details - first 3 columns reordered, is_native_range 4th, lan_interface_index 5th
980
+ 'lan_interface_id': interface_id if is_first_range else '',
981
+ 'lan_interface_name': interface_name if is_first_range else '',
982
+ 'lan_interface_dest_type': interface_dest_type if is_first_range else '',
983
+ 'is_native_range': 'TRUE' if is_native_range else '',
984
+ 'lan_interface_index': interface_index, # Populated for every row
985
+
986
+ # Network Range details (on all rows)
987
+ 'network_range_id': network_range.get('id', ''),
988
+ 'network_range_name': network_range.get('name', ''),
989
+ 'subnet': network_range.get('subnet', ''),
990
+ 'vlan': network_range.get('vlan', ''),
991
+ 'mdns_reflector': str(network_range.get('mdns_reflector', False)).upper(),
992
+ 'gateway': network_range.get('gateway', ''),
993
+ 'range_type': network_range.get('range_type', ''),
994
+ 'translated_subnet': network_range.get('translated_subnet', ''),
995
+ 'local_ip': network_range.get('local_ip', ''),
996
+ 'dhcp_type': network_range.get('dhcp_settings', {}).get('dhcp_type', ''),
997
+ 'dhcp_ip_range': network_range.get('dhcp_settings', {}).get('ip_range', ''),
998
+ 'dhcp_relay_group_id': network_range.get('dhcp_settings', {}).get('relay_group_id', ''),
999
+ 'dhcp_relay_group_name': network_range.get('dhcp_settings', {}).get('relay_group_name', ''),
1000
+ 'dhcp_microsegmentation': str(network_range.get('dhcp_settings', {}).get('dhcp_microsegmentation', False)).upper()
1001
+ }
1002
+ rows.append(row)
1003
+
1004
+ # If still no rows, it means the site only has the default LAN interface (managed at parent level)
1005
+ # In this case, create an empty CSV file - no entries needed since default interface is handled in parent CSV
1006
+ # This is correct behavior: sites with only default LAN interfaces should have empty site-level CSV files
1007
+
1008
+ # Always create file now (removed the early return)
1009
+ # if not rows:
1010
+ # return None
1011
+
1012
+ with open(filepath, 'w', newline='', encoding='utf-8') as csvfile:
1013
+ writer = csv.DictWriter(csvfile, fieldnames=headers)
1014
+ writer.writeheader()
1015
+ writer.writerows(rows)
1016
+
1017
+ if hasattr(args, 'verbose') and args.verbose:
1018
+ print(f"Exported {len(rows)} network ranges for site '{site_name}' to {filepath}")
1019
+
1020
+ return filepath
1021
+
1022
+
256
1023
  ##########################################################################
257
1024
  ########################### Helper functions #############################
258
1025
  ##########################################################################
@@ -277,13 +1044,16 @@ def populateSiteLocationData(args, site_data, cur_site):
277
1044
  if hasattr(args, 'verbose') and args.verbose:
278
1045
  print(f"Warning: Could not load site location data: {e}")
279
1046
 
1047
+ address = site_data.get('infoSiteSnapshot', {}).get('address')
1048
+ city = site_data.get('infoSiteSnapshot', {}).get('cityName')
1049
+
280
1050
  ## siteLocation attributes
281
1051
  cur_site['site_location'] = {}
282
- cur_site['site_location']['address'] = site_data.get('infoSiteSnapshot', {}).get('address')
283
- cur_site['site_location']['city'] = site_data.get('infoSiteSnapshot', {}).get('cityName')
284
1052
  cur_site['site_location']['stateName'] = site_data.get('infoSiteSnapshot', {}).get('countryStateName')
285
1053
  cur_site['site_location']['countryCode'] = site_data.get('infoSiteSnapshot', {}).get('countryCode')
286
1054
  cur_site['site_location']['countryName'] = site_data.get('infoSiteSnapshot', {}).get('countryName')
1055
+ cur_site['site_location']['address'] = address if address != "" else None
1056
+ cur_site['site_location']['city'] = city if city != "" else None
287
1057
 
288
1058
  # Look up timezone and state code from location data
289
1059
  country_name = cur_site['site_location']['countryName']
@@ -303,6 +1073,9 @@ def populateSiteLocationData(args, site_data, cur_site):
303
1073
  # Look up location details
304
1074
  location_data = site_location_data.get(lookup_key, {})
305
1075
 
1076
+ # Now that location_data is defined, we can set stateCode
1077
+ cur_site['site_location']['stateCode'] = location_data.get('stateCode', None)
1078
+
306
1079
  if hasattr(args, 'verbose') and args.verbose:
307
1080
  if location_data:
308
1081
  print(f" Found location data: {location_data}")
@@ -313,8 +1086,7 @@ def populateSiteLocationData(args, site_data, cur_site):
313
1086
  if similar_keys:
314
1087
  print(f" Similar keys found: {similar_keys}")
315
1088
 
316
- cur_site['stateCode'] = location_data.get('stateCode', None)
317
-
1089
+
318
1090
  # Get timezone - always use the 0 element in the timezones array
319
1091
  timezones = location_data.get('timezone', [])
320
1092
  cur_site['site_location']['timezone'] = timezones[0] if timezones else None
@@ -372,6 +1144,7 @@ def getAccountSnapshot(args, configuration, account_id, site_ids=None):
372
1144
  raise ValueError("Failed to retrieve snapshot data from API")
373
1145
 
374
1146
  if not response or 'sites' not in response['data']['accountSnapshot'] or response['data']['accountSnapshot']['sites'] is None:
375
- raise ValueError("No sites found in account snapshot data from API")
1147
+ # Instead of raising an exception, return an empty response structure
1148
+ response['data']['accountSnapshot']['sites'] = []
376
1149
 
377
1150
  return response