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

@@ -3,6 +3,7 @@ import json
3
3
  import traceback
4
4
  import sys
5
5
  import ipaddress
6
+ import csv
6
7
  from datetime import datetime
7
8
  from graphql_client.api.call_api import ApiClient, CallApi
8
9
  from graphql_client.api_client import ApiException
@@ -40,6 +41,18 @@ def calculateLocalIp(subnet):
40
41
  # Invalid subnet format
41
42
  return None
42
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
+
43
56
  def export_socket_site_to_json(args, configuration):
44
57
  """
45
58
  Export consolidated site and socket data to JSON format
@@ -55,8 +68,7 @@ def export_socket_site_to_json(args, configuration):
55
68
  try:
56
69
  # Load CLI settings using the robust function
57
70
  settings = load_cli_settings()
58
- if not settings:
59
- raise ValueError("Unable to load clisettings.json. Cannot proceed with export.")
71
+ # Note: load_cli_settings() now returns embedded defaults if file cannot be loaded
60
72
 
61
73
  account_id = getAccountID(args, configuration)
62
74
  # Get account snapshot with siteIDs if provided
@@ -136,7 +148,12 @@ def export_socket_site_to_json(args, configuration):
136
148
  cur_wan_interface['id'] = site_id+":"+ wan_ni.get('id', "")
137
149
  else:
138
150
  cur_wan_interface['id'] = site_id+":INT_"+ wan_ni.get('id', "")
139
- cur_wan_interface['index'] = 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
140
157
  cur_wan_interface['name'] = wan_ni.get('name', "")
141
158
  cur_wan_interface['upstream_bandwidth'] = wan_ni.get('upstreamBandwidth', 0)
142
159
  cur_wan_interface['downstream_bandwidth'] = wan_ni.get('downstreamBandwidth', 0)
@@ -174,7 +191,7 @@ def export_socket_site_to_json(args, configuration):
174
191
  cur_site_entry["native_range"]["subnet"] = lan_ni_subnet
175
192
  cur_site_entry["native_range"]["index"] = ni_index
176
193
  # Add entry to lan interfaces for default_lan
177
- cur_site_entry['lan_interfaces'].append({"network_ranges": [],"default_lan":True})
194
+ cur_site_entry['lan_interfaces'].append({"network_ranges": [],"default_lan":True})
178
195
  else:
179
196
  cur_lan_interface['id'] = ni_interface_id
180
197
  cur_lan_interface['name'] = ni_interface_name
@@ -185,7 +202,9 @@ def export_socket_site_to_json(args, configuration):
185
202
  cur_site_entry['lan_interfaces'].append(cur_lan_interface)
186
203
  else:
187
204
  if hasattr(args, 'verbose') and args.verbose:
188
- 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})")
189
208
 
190
209
  #############################################################################
191
210
  ## Process entity lookup network ranges populating by network interface id ##
@@ -218,7 +237,7 @@ def export_socket_site_to_json(args, configuration):
218
237
  nr_relay_group_name = nr_helper_fields.get('XXXXX', None)
219
238
  nr_gateway = nr_helper_fields.get('XXXXX', None)
220
239
  nr_translated_subnet = nr_helper_fields.get('XXXXX', None)
221
- # nr_internet_only = nr_helper_fields.get('XXXXX', None)
240
+ nr_internet_only = nr_helper_fields.get('XXXXX', None) # Default to None for JSON
222
241
  nr_local_ip = nr_helper_fields.get('XXXXX', None)
223
242
  nr_range_type = nr_helper_fields.get('XXXXX', None)
224
243
  # Adding logic to pre-populate with default value
@@ -252,8 +271,7 @@ def export_socket_site_to_json(args, configuration):
252
271
  cur_range['gateway'] = nr_gateway
253
272
  cur_range['range_type'] = nr_range_type
254
273
  cur_range['translated_subnet'] = nr_translated_subnet
255
- # # Not available to set for native_range via API today
256
- # cur_range['internet_only'] = nr_internet_only
274
+ cur_range['internet_only'] = nr_internet_only
257
275
  cur_range['local_ip'] = nr_local_ip # Use the calculated or original value
258
276
  cur_range['dhcp_settings'] = {
259
277
  'dhcp_type': nr_dhcp_type,
@@ -272,8 +290,7 @@ def export_socket_site_to_json(args, configuration):
272
290
  site_native_range['gateway'] = nr_gateway
273
291
  site_native_range['range_type'] = nr_range_type
274
292
  site_native_range['translated_subnet'] = nr_translated_subnet
275
- # # Not available to set for native_range via API today
276
- # site_native_range['internet_only'] = nr_internet_only
293
+ site_native_range['internet_only'] = nr_internet_only
277
294
  site_native_range['local_ip'] = nr_local_ip
278
295
  site_native_range['dhcp_settings'] = {
279
296
  'dhcp_type': nr_dhcp_type,
@@ -295,8 +312,7 @@ def export_socket_site_to_json(args, configuration):
295
312
  cur_range['gateway'] = nr_gateway
296
313
  cur_range['range_type'] = nr_range_type
297
314
  cur_range['translated_subnet'] = nr_translated_subnet
298
- # # Not available to set for native_range via API today
299
- # cur_range['internet_only'] = nr_internet_only
315
+ cur_range['internet_only'] = nr_internet_only
300
316
  cur_range['local_ip'] = nr_local_ip # Use the calculated or original value
301
317
  cur_range['dhcp_settings'] = {
302
318
  'dhcp_type': nr_dhcp_type,
@@ -390,6 +406,622 @@ def export_socket_site_to_json(args, configuration):
390
406
  return [{"success": False, "error": str(e), "error_details": error_details}]
391
407
 
392
408
 
409
+ def export_socket_site_to_csv(args, configuration):
410
+ """
411
+ Export consolidated site and socket data to CSV format
412
+ Creates main sites CSV and individual network ranges CSV files
413
+ """
414
+ try:
415
+ # First get the JSON data using existing function
416
+ json_result = export_socket_site_to_json(args, configuration)
417
+
418
+ if not json_result or not json_result[0].get('success'):
419
+ return json_result
420
+
421
+ # Load the processed data from the JSON function
422
+ # We'll re-use the data processing logic but output to CSV instead
423
+ processed_data = get_processed_site_data(args, configuration)
424
+
425
+ if not processed_data or not processed_data.get('sites'):
426
+ return [{"success": False, "error": "No sites data found to export"}]
427
+
428
+ account_id = getAccountID(args, configuration)
429
+ output_files = []
430
+
431
+ # Export main sites CSV
432
+ sites_csv_file = export_sites_to_csv(processed_data['sites'], args, account_id)
433
+ output_files.append(sites_csv_file)
434
+
435
+ # Export individual network ranges CSV files for each site
436
+ for site in processed_data['sites']:
437
+ ranges_csv_file = export_network_ranges_to_csv(site, args, account_id)
438
+ if ranges_csv_file:
439
+ output_files.append(ranges_csv_file)
440
+
441
+ return [{"success": True, "output_files": output_files, "account_id": account_id}]
442
+
443
+ except Exception as e:
444
+ exc_type, exc_value, exc_traceback = sys.exc_info()
445
+ line_number = exc_traceback.tb_lineno
446
+ filename = exc_traceback.tb_frame.f_code.co_filename
447
+ function_name = exc_traceback.tb_frame.f_code.co_name
448
+ full_traceback = traceback.format_exc()
449
+
450
+ error_details = {
451
+ "error_type": exc_type.__name__,
452
+ "error_message": str(exc_value),
453
+ "line_number": line_number,
454
+ "function_name": function_name,
455
+ "filename": os.path.basename(filename),
456
+ "full_traceback": full_traceback
457
+ }
458
+
459
+ print(f"ERROR: {exc_type.__name__}: {str(exc_value)}")
460
+ print(f"Location: {os.path.basename(filename)}:{line_number} in {function_name}()")
461
+ print(f"Full traceback:\n{full_traceback}")
462
+
463
+ return [{"success": False, "error": str(e), "error_details": error_details}]
464
+
465
+
466
+ def get_processed_site_data(args, configuration):
467
+ """
468
+ Get processed site data without writing to JSON file
469
+ Reuses the logic from export_socket_site_to_json but returns data instead
470
+ """
471
+ processed_data = {'sites': []}
472
+
473
+ # Load CLI settings
474
+ settings = load_cli_settings()
475
+ # Note: load_cli_settings() now returns embedded defaults if file cannot be loaded
476
+
477
+ account_id = getAccountID(args, configuration)
478
+
479
+ # Get siteIDs from args if provided
480
+ site_ids = []
481
+ if hasattr(args, 'siteIDs') and args.siteIDs:
482
+ site_ids = [site_id.strip() for site_id in args.siteIDs.split(',') if site_id.strip()]
483
+
484
+ # Call APIs to retrieve sites, interface and network ranges
485
+ snapshot_sites = getAccountSnapshot(args, configuration, account_id, site_ids)
486
+ sites_list = snapshot_sites['data']['accountSnapshot']['sites']
487
+
488
+ if not sites_list or len(sites_list) == 0:
489
+ return processed_data
490
+
491
+ entity_network_interfaces = getEntityLookup(args, configuration, account_id, "networkInterface")
492
+ entity_network_ranges = getEntityLookup(args, configuration, account_id, "siteRange")
493
+ entity_sites = getEntityLookup(args, configuration, account_id, "site")
494
+
495
+ # Process sites (reuse existing logic)
496
+ for snapshot_site in snapshot_sites['data']['accountSnapshot']['sites']:
497
+ site_id = snapshot_site.get('id')
498
+ connectionType = snapshot_site.get('infoSiteSnapshot', {}).get('connType', "")
499
+
500
+ cur_site = {
501
+ 'wan_interfaces': [],
502
+ 'lan_interfaces': [],
503
+ 'native_range': {}
504
+ }
505
+
506
+ if connectionType in settings["export_by_socket_type"]:
507
+ cur_site['id'] = site_id
508
+ cur_site['name'] = snapshot_site.get('infoSiteSnapshot', {}).get('name')
509
+ cur_site['description'] = snapshot_site.get('infoSiteSnapshot', {}).get('description')
510
+ cur_site['connection_type'] = connectionType
511
+ cur_site['type'] = snapshot_site.get('infoSiteSnapshot', {}).get('type')
512
+ cur_site = populateSiteLocationData(args, snapshot_site, cur_site)
513
+
514
+ # Process WAN interfaces
515
+ site_interfaces = snapshot_site.get('infoSiteSnapshot', {}).get('interfaces', [])
516
+ for wan_ni in site_interfaces:
517
+ cur_wan_interface = {}
518
+ role = wan_ni.get('wanRoleInterfaceInfo', "")
519
+ interfaceName = wan_ni.get('id', "")
520
+ if role is not None and role[0:3] == "wan":
521
+ if interfaceName[0:3] in ("WAN", "USB", "LTE"):
522
+ cur_wan_interface['id'] = site_id+":"+ wan_ni.get('id', "")
523
+ else:
524
+ cur_wan_interface['id'] = site_id+":INT_"+ wan_ni.get('id', "")
525
+ # Format WAN interface index: INT_X for numeric values, keep as-is for non-numeric
526
+ wan_interface_id = wan_ni.get('id', "")
527
+ if isinstance(wan_interface_id, (int, str)) and str(wan_interface_id).isdigit():
528
+ cur_wan_interface['index'] = f"INT_{wan_interface_id}"
529
+ else:
530
+ cur_wan_interface['index'] = wan_interface_id
531
+ cur_wan_interface['name'] = wan_ni.get('name', "")
532
+ cur_wan_interface['upstream_bandwidth'] = wan_ni.get('upstreamBandwidth', 0)
533
+ cur_wan_interface['downstream_bandwidth'] = wan_ni.get('downstreamBandwidth', 0)
534
+ cur_wan_interface['dest_type'] = wan_ni.get('destType', "")
535
+ cur_wan_interface['role'] = role
536
+ cur_wan_interface['precedence'] = "ACTIVE"
537
+ cur_site['wan_interfaces'].append(cur_wan_interface)
538
+
539
+ if site_id:
540
+ processed_data['sites'].append(cur_site)
541
+
542
+ # Process LAN interfaces (reuse existing logic)
543
+ for lan_ni in entity_network_interfaces:
544
+ lan_ni_helper_fields = lan_ni.get("helperFields", {})
545
+ lan_ni_entity_data = lan_ni.get('entity', {})
546
+ lan_ni_site_id = str(lan_ni_helper_fields.get('siteId', ""))
547
+ cur_site_entry = next((site for site in processed_data['sites'] if site['id'] == lan_ni_site_id), None)
548
+ if cur_site_entry:
549
+ cur_lan_interface = {'network_ranges': []}
550
+ ni_interface_id = lan_ni_entity_data.get('id', "")
551
+ ni_interface_name = lan_ni_helper_fields.get('interfaceName', "")
552
+ lan_ni_subnet = str(lan_ni_helper_fields.get('subnet', ""))
553
+ ni_index = lan_ni_helper_fields.get('interfaceId', "")
554
+ ni_index = f"INT_{ni_index}" if isinstance(ni_index, (int, str)) and str(ni_index).isdigit() else ni_index
555
+
556
+ 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"]]:
557
+ cur_site_entry["native_range"]["interface_id"] = ni_interface_id
558
+ cur_site_entry["native_range"]["interface_name"] = ni_interface_name
559
+ cur_site_entry["native_range"]["subnet"] = lan_ni_subnet
560
+ cur_site_entry["native_range"]["index"] = ni_index
561
+ cur_site_entry['lan_interfaces'].append({"network_ranges": [], "default_lan": True})
562
+ else:
563
+ cur_lan_interface['id'] = ni_interface_id
564
+ cur_lan_interface['name'] = ni_interface_name
565
+ cur_lan_interface['index'] = ni_index
566
+ cur_lan_interface['dest_type'] = lan_ni_helper_fields.get('destType', "")
567
+ cur_lan_interface['subnet'] = lan_ni_subnet
568
+ cur_site_entry['lan_interfaces'].append(cur_lan_interface)
569
+
570
+ # Process network ranges (reuse existing logic)
571
+ for range in entity_network_ranges:
572
+ nr_helper_fields = range.get("helperFields", {})
573
+ nr_entity_data = range.get('entity', {})
574
+ nr_interface_name = str(nr_helper_fields.get('interfaceName', ""))
575
+ nr_site_id = str(nr_helper_fields.get('siteId', ""))
576
+ range_id = nr_entity_data.get('id', "")
577
+
578
+ nr_site_entry = next((site for site in processed_data['sites'] if site['id'] == nr_site_id), None)
579
+ if nr_site_entry:
580
+ nr_subnet = nr_helper_fields.get('subnet', None)
581
+ nr_vlan = nr_helper_fields.get('vlanTag', None)
582
+ nr_mdns_reflector = nr_helper_fields.get('mdnsReflector', False)
583
+ nr_dhcp_microsegmentation = nr_helper_fields.get('microsegmentation', False)
584
+ range_name = nr_entity_data.get('name', nr_interface_name)
585
+ if range_name and " \\ " in range_name:
586
+ range_name = range_name.split(" \\ ").pop()
587
+
588
+ # Set defaults for missing fields
589
+ nr_dhcp_type = "DHCP_DISABLED"
590
+ nr_ip_range = None
591
+ nr_relay_group_name = None
592
+ nr_gateway = None
593
+ nr_translated_subnet = None
594
+ nr_internet_only = None # Default to None for JSON
595
+ nr_local_ip = None
596
+ nr_range_type = "VLAN" if nr_vlan != None else "Direct"
597
+
598
+ # Calculate local IP if requested
599
+ if hasattr(args, 'calculate_local_ip') and args.calculate_local_ip and nr_subnet:
600
+ calculated_ip = calculateLocalIp(nr_subnet)
601
+ if calculated_ip:
602
+ nr_local_ip = calculated_ip
603
+
604
+ site_native_range = nr_site_entry.get('native_range', {})
605
+
606
+ if site_native_range.get("interface_name", "") == nr_interface_name:
607
+ if range_name != "Native Range":
608
+ 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)
609
+ if nr_lan_interface_entry:
610
+ cur_range = {
611
+ 'id': range_id, 'name': range_name, 'subnet': nr_subnet, 'vlan': nr_vlan,
612
+ 'mdns_reflector': nr_mdns_reflector, 'gateway': nr_gateway, 'range_type': nr_range_type,
613
+ 'translated_subnet': nr_translated_subnet, 'internet_only': nr_internet_only, 'local_ip': nr_local_ip,
614
+ 'dhcp_settings': {
615
+ 'dhcp_type': nr_dhcp_type, 'ip_range': nr_ip_range, 'relay_group_id': None,
616
+ 'relay_group_name': nr_relay_group_name, 'dhcp_microsegmentation': nr_dhcp_microsegmentation
617
+ }
618
+ }
619
+ nr_lan_interface_entry["network_ranges"].append(cur_range)
620
+ else:
621
+ site_native_range['range_name'] = range_name
622
+ site_native_range['range_id'] = range_id
623
+ site_native_range['vlan'] = nr_vlan
624
+ site_native_range['mdns_reflector'] = nr_mdns_reflector
625
+ site_native_range['gateway'] = nr_gateway
626
+ site_native_range['range_type'] = nr_range_type
627
+ site_native_range['translated_subnet'] = nr_translated_subnet
628
+ site_native_range['internet_only'] = nr_internet_only
629
+ site_native_range['local_ip'] = nr_local_ip
630
+ site_native_range['dhcp_settings'] = {
631
+ 'dhcp_type': nr_dhcp_type, 'ip_range': nr_ip_range, 'relay_group_id': None,
632
+ 'relay_group_name': nr_relay_group_name, 'dhcp_microsegmentation': nr_dhcp_microsegmentation
633
+ }
634
+ else:
635
+ 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)
636
+ if nr_lan_interface_entry:
637
+ cur_range = {
638
+ 'id': range_id, 'name': range_name, 'subnet': nr_subnet, 'vlan': nr_vlan,
639
+ 'mdns_reflector': nr_mdns_reflector, 'gateway': nr_gateway, 'range_type': nr_range_type,
640
+ 'translated_subnet': nr_translated_subnet, 'internet_only': nr_internet_only, 'local_ip': nr_local_ip,
641
+ 'dhcp_settings': {
642
+ 'dhcp_type': nr_dhcp_type, 'ip_range': nr_ip_range, 'relay_group_id': None,
643
+ 'relay_group_name': nr_relay_group_name, 'dhcp_microsegmentation': nr_dhcp_microsegmentation
644
+ }
645
+ }
646
+
647
+ if "subnet" in nr_lan_interface_entry and nr_subnet == nr_lan_interface_entry["subnet"]:
648
+ cur_range['native_range'] = True
649
+ del nr_lan_interface_entry["subnet"]
650
+
651
+ nr_lan_interface_entry["network_ranges"].append(cur_range)
652
+
653
+ return processed_data
654
+
655
+
656
+ def export_sites_to_csv(sites, args, account_id):
657
+ """
658
+ Export main sites data to CSV file in bulk-sites format
659
+ One row per WAN interface, site attributes only on first row per site
660
+ """
661
+ # Handle custom filename and timestamp
662
+ if hasattr(args, 'csv_filename') and args.csv_filename:
663
+ base_filename = args.csv_filename
664
+ if base_filename.endswith('.csv'):
665
+ base_filename = base_filename[:-4]
666
+
667
+ if hasattr(args, 'append_timestamp') and args.append_timestamp:
668
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
669
+ filename_template = f"{base_filename}_{timestamp}.csv"
670
+ else:
671
+ filename_template = f"{base_filename}.csv"
672
+ else:
673
+ if hasattr(args, 'append_timestamp') and args.append_timestamp:
674
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
675
+ filename_template = f"socket_sites_{{account_id}}_{timestamp}.csv"
676
+ else:
677
+ filename_template = "socket_sites_{account_id}.csv"
678
+
679
+ # Replace account_id placeholder
680
+ filename = filename_template.format(account_id=account_id)
681
+
682
+ # Determine output directory
683
+ output_dir = getattr(args, 'output_directory', None)
684
+ if not output_dir:
685
+ output_dir = 'config_data'
686
+ if not os.path.isabs(output_dir):
687
+ output_dir = os.path.join(os.getcwd(), output_dir)
688
+
689
+ os.makedirs(output_dir, exist_ok=True)
690
+ filepath = os.path.join(output_dir, filename)
691
+
692
+ # Define CSV headers matching bulk-sites.csv format
693
+ headers = [
694
+ 'site_id',
695
+ 'site_name',
696
+ 'wan_interface_id',
697
+ 'wan_interface_index',
698
+ 'wan_interface_name',
699
+ 'wan_upstream_bw',
700
+ 'wan_downstream_bw',
701
+ 'wan_role',
702
+ 'wan_precedence',
703
+ 'site_description',
704
+ 'native_range_subnet',
705
+ 'native_range_name',
706
+ 'native_range_id',
707
+ 'native_range_vlan',
708
+ 'native_range_mdns_reflector',
709
+ 'native_range_gateway',
710
+ 'native_range_type',
711
+ 'native_range_translated_subnet',
712
+ 'native_range_interface_id',
713
+ 'native_range_interface_index',
714
+ 'native_range_interface_name',
715
+ 'native_range_dhcp_type',
716
+ 'native_range_dhcp_ip_range',
717
+ 'native_range_dhcp_relay_group_id',
718
+ 'native_range_dhcp_relay_group_name',
719
+ 'native_range_dhcp_microsegmentation',
720
+ 'native_range_local_ip',
721
+ 'site_type',
722
+ 'connection_type',
723
+ 'site_location_address',
724
+ 'site_location_city',
725
+ 'site_location_country_code',
726
+ 'site_location_state_code',
727
+ 'site_location_timezone'
728
+ ]
729
+
730
+ rows = []
731
+ total_interfaces = 0
732
+
733
+ for site in sites:
734
+ site_name = site.get('name', '')
735
+ wan_interfaces = site.get('wan_interfaces', [])
736
+
737
+ # Sort WAN interfaces to ensure 'wan_1' role appears first
738
+ # This organizes the CSV so wan_1 interfaces are on the first line for each site
739
+ def wan_interface_sort_key(interface):
740
+ role = interface.get('role', '').lower()
741
+ if role == 'wan_1':
742
+ return 0 # wan_1 comes first
743
+ elif role == 'wan_2':
744
+ return 1
745
+ elif role == 'wan_3':
746
+ return 2
747
+ elif role == 'wan_4':
748
+ return 3
749
+ else:
750
+ return 9 # Unknown/other roles come last
751
+
752
+ wan_interfaces.sort(key=wan_interface_sort_key)
753
+
754
+ # If no WAN interfaces, create one empty row for the site
755
+ if not wan_interfaces:
756
+ wan_interfaces = [{}] # Empty interface to ensure site is included
757
+
758
+ for idx, wan_interface in enumerate(wan_interfaces):
759
+ # Site-specific attributes only on first row per site
760
+ is_first_interface = (idx == 0)
761
+
762
+ # Calculate local IP from native range subnet if available
763
+ native_range = site.get('native_range', {})
764
+ native_subnet = native_range.get('subnet', '')
765
+ local_ip = ''
766
+ if native_subnet and hasattr(args, 'calculate_local_ip') and args.calculate_local_ip:
767
+ calculated_ip = calculateLocalIp(native_subnet)
768
+ if calculated_ip:
769
+ local_ip = calculated_ip
770
+ elif native_range.get('local_ip'):
771
+ local_ip = native_range.get('local_ip', '')
772
+
773
+ row = {
774
+ 'site_id': site.get('id', '') if is_first_interface else '',
775
+ 'site_name': site_name,
776
+ 'wan_interface_id': wan_interface.get('id', '') if wan_interface else '',
777
+ 'wan_interface_index': wan_interface.get('index', '') if wan_interface else '',
778
+ 'wan_interface_name': wan_interface.get('name', '') if wan_interface else '',
779
+ 'wan_upstream_bw': wan_interface.get('upstream_bandwidth', '') if wan_interface else '',
780
+ 'wan_downstream_bw': wan_interface.get('downstream_bandwidth', '') if wan_interface else '',
781
+ 'wan_role': wan_interface.get('role', '') if wan_interface else '',
782
+ 'wan_precedence': wan_interface.get('precedence', '') if wan_interface else '',
783
+
784
+ # Site attributes - only populate on first interface row
785
+ 'site_description': site.get('description', '') if is_first_interface else '',
786
+ 'native_range_subnet': native_subnet if is_first_interface else '',
787
+ 'native_range_name': native_range.get('range_name', '') if is_first_interface else '',
788
+ 'native_range_id': native_range.get('range_id', '') if is_first_interface else '',
789
+ 'native_range_vlan': native_range.get('vlan', '') if is_first_interface else '',
790
+ '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 '',
791
+ 'native_range_gateway': native_range.get('gateway', '') if is_first_interface else '',
792
+ 'native_range_type': native_range.get('range_type', '') if is_first_interface else '',
793
+ 'native_range_translated_subnet': native_range.get('translated_subnet', '') if is_first_interface else '',
794
+ 'native_range_interface_id': native_range.get('interface_id', '') if is_first_interface else '',
795
+ 'native_range_interface_index': native_range.get('index', '') if is_first_interface else '',
796
+ 'native_range_interface_name': native_range.get('interface_name', '') if is_first_interface else '',
797
+ 'native_range_dhcp_type': native_range.get('dhcp_settings', {}).get('dhcp_type', '') if is_first_interface else '',
798
+ 'native_range_dhcp_ip_range': native_range.get('dhcp_settings', {}).get('ip_range', '') if is_first_interface else '',
799
+ 'native_range_dhcp_relay_group_id': native_range.get('dhcp_settings', {}).get('relay_group_id', '') if is_first_interface else '',
800
+ 'native_range_dhcp_relay_group_name': native_range.get('dhcp_settings', {}).get('relay_group_name', '') if is_first_interface else '',
801
+ '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 '',
802
+ 'native_range_local_ip': local_ip if is_first_interface else '',
803
+ 'site_type': site.get('type', '') if is_first_interface else '',
804
+ 'connection_type': site.get('connection_type', '') if is_first_interface else '',
805
+ 'site_location_address': site.get('site_location', {}).get('address', '') if is_first_interface else '',
806
+ 'site_location_city': site.get('site_location', {}).get('city', '') if is_first_interface else '',
807
+ 'site_location_country_code': site.get('site_location', {}).get('countryCode', '') if is_first_interface else '',
808
+ 'site_location_state_code': site.get('site_location', {}).get('stateCode', '') if is_first_interface else '',
809
+ 'site_location_timezone': site.get('site_location', {}).get('timezone', '') if is_first_interface else ''
810
+ }
811
+
812
+ rows.append(row)
813
+ total_interfaces += 1 if wan_interface else 0
814
+
815
+ with open(filepath, 'w', newline='', encoding='utf-8') as csvfile:
816
+ writer = csv.DictWriter(csvfile, fieldnames=headers)
817
+ writer.writeheader()
818
+ writer.writerows(rows)
819
+
820
+ if hasattr(args, 'verbose') and args.verbose:
821
+ print(f"Exported {len(sites)} sites with {total_interfaces} WAN interfaces to {filepath}")
822
+
823
+ return filepath
824
+
825
+
826
+ def export_network_ranges_to_csv(site, args, account_id):
827
+ """
828
+ Export network ranges for a single site to CSV file
829
+ Structure: LAN interface as parent with network ranges as children
830
+ First row per interface contains interface details, subsequent rows contain only network range details
831
+ """
832
+ site_name = site.get('name', '')
833
+ if not site_name:
834
+ return None
835
+
836
+ # Sanitize site name for filename
837
+ safe_site_name = "".join(c for c in site_name if c.isalnum() or c in ('-', '_')).rstrip()
838
+
839
+ # Determine output directory
840
+ output_dir = getattr(args, 'output_directory', None)
841
+ if not output_dir:
842
+ output_dir = 'config_data'
843
+ if not os.path.isabs(output_dir):
844
+ output_dir = os.path.join(os.getcwd(), output_dir)
845
+
846
+ # Create sites_config subdirectory with account ID
847
+ sites_config_dir = os.path.join(output_dir, f'sites_config_{account_id}')
848
+ os.makedirs(sites_config_dir, exist_ok=True)
849
+
850
+ filename = f"{safe_site_name}_network_ranges.csv"
851
+ filepath = os.path.join(sites_config_dir, filename)
852
+
853
+ # Define CSV headers - Reordered LAN interface columns first, then network range columns
854
+ headers = [
855
+ # LAN Interface columns (first 3 columns, is_native_range 4th, lan_interface_index 5th)
856
+ 'lan_interface_id', 'lan_interface_name', 'lan_interface_dest_type', 'is_native_range', 'lan_interface_index',
857
+ # Network Range columns (populated on all rows)
858
+ 'network_range_id', 'network_range_name', 'subnet', 'vlan', 'mdns_reflector',
859
+ 'gateway', 'range_type', 'translated_subnet', 'internet_only', 'local_ip',
860
+ 'dhcp_type', 'dhcp_ip_range', 'dhcp_relay_group_id', 'dhcp_relay_group_name', 'dhcp_microsegmentation'
861
+ ]
862
+
863
+ rows = []
864
+
865
+ # Get the native range subnet from the site to exclude it from detailed CSV
866
+ native_range_subnet = site.get('native_range', {}).get('subnet', '')
867
+
868
+ # Process default LAN interface (from native_range) - ONLY if it has additional networks
869
+ native_range = site.get('native_range', {})
870
+ native_range_index = native_range.get('index', '') # The default interface index from parent CSV
871
+ default_lan_interfaces = [lan_nic for lan_nic in site.get('lan_interfaces', []) if lan_nic.get('default_lan', False)]
872
+
873
+ if default_lan_interfaces:
874
+ for default_lan_interface in default_lan_interfaces:
875
+ interface_id = native_range.get('interface_id', '')
876
+ interface_name = native_range.get('interface_name', '')
877
+ interface_index = native_range.get('index', '') # Use actual interface index like INT_5
878
+ interface_dest_type = 'LAN'
879
+
880
+ network_ranges = default_lan_interface.get('network_ranges', [])
881
+
882
+ # Filter out the network range that matches the parent CSV native_range_subnet
883
+ filtered_ranges = [nr for nr in network_ranges if nr.get('subnet', '') != native_range_subnet]
884
+
885
+ # For default_lan interfaces, ONLY create entries if there are additional ranges
886
+ # Do NOT create an entry with is_native_range=TRUE since native range is managed at parent level
887
+ if filtered_ranges:
888
+ # Process filtered ranges - all marked as additional (non-native) ranges
889
+ for idx, network_range in enumerate(filtered_ranges):
890
+ # First row for this interface includes interface details
891
+ is_first_range = (idx == 0)
892
+
893
+ # For default_lan interfaces, all ranges are additional (is_native_range=FALSE)
894
+ # because the native range is already defined in the parent CSV
895
+
896
+ row = {
897
+ # LAN Interface details - for default LAN interfaces, don't populate interface ID, name, type since managed at parent level
898
+ 'lan_interface_id': '', # Empty for default LAN interfaces (managed at parent level)
899
+ 'lan_interface_name': '', # Empty for default LAN interfaces (managed at parent level)
900
+ 'lan_interface_dest_type': '', # Empty for default LAN interfaces (managed at parent level)
901
+ 'is_native_range': '', # Always empty for default LAN interfaces
902
+ 'lan_interface_index': interface_index, # Populated for every row
903
+
904
+ # Network Range details (on all rows)
905
+ 'network_range_id': network_range.get('id', ''),
906
+ 'network_range_name': network_range.get('name', ''),
907
+ 'subnet': network_range.get('subnet', ''),
908
+ 'vlan': network_range.get('vlan', ''),
909
+ 'mdns_reflector': str(network_range.get('mdns_reflector', False)).upper(),
910
+ 'gateway': network_range.get('gateway', ''),
911
+ 'range_type': network_range.get('range_type', ''),
912
+ 'translated_subnet': network_range.get('translated_subnet', ''),
913
+ 'internet_only': network_range.get('internet_only', ''),
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
+ 'internet_only': '',
962
+ 'local_ip': '',
963
+ 'dhcp_type': '',
964
+ 'dhcp_ip_range': '',
965
+ 'dhcp_relay_group_id': '',
966
+ 'dhcp_relay_group_name': '',
967
+ 'dhcp_microsegmentation': ''
968
+ }
969
+ rows.append(row)
970
+ else:
971
+ # Process filtered ranges as before
972
+ for idx, network_range in enumerate(filtered_ranges):
973
+ # First row for this interface includes interface details
974
+ is_first_range = (idx == 0)
975
+
976
+ # For non-default_lan interfaces, first range should be marked as is_native_range
977
+ is_native_range = is_first_range
978
+
979
+ row = {
980
+ # LAN Interface details - first 3 columns reordered, is_native_range 4th, lan_interface_index 5th
981
+ 'lan_interface_id': interface_id if is_first_range else '',
982
+ 'lan_interface_name': interface_name if is_first_range else '',
983
+ 'lan_interface_dest_type': interface_dest_type if is_first_range else '',
984
+ 'is_native_range': 'TRUE' if is_native_range else '',
985
+ 'lan_interface_index': interface_index, # Populated for every row
986
+
987
+ # Network Range details (on all rows)
988
+ 'network_range_id': network_range.get('id', ''),
989
+ 'network_range_name': network_range.get('name', ''),
990
+ 'subnet': network_range.get('subnet', ''),
991
+ 'vlan': network_range.get('vlan', ''),
992
+ 'mdns_reflector': str(network_range.get('mdns_reflector', False)).upper(),
993
+ 'gateway': network_range.get('gateway', ''),
994
+ 'range_type': network_range.get('range_type', ''),
995
+ 'translated_subnet': network_range.get('translated_subnet', ''),
996
+ 'internet_only': network_range.get('internet_only', ''),
997
+ 'local_ip': network_range.get('local_ip', ''),
998
+ 'dhcp_type': network_range.get('dhcp_settings', {}).get('dhcp_type', ''),
999
+ 'dhcp_ip_range': network_range.get('dhcp_settings', {}).get('ip_range', ''),
1000
+ 'dhcp_relay_group_id': network_range.get('dhcp_settings', {}).get('relay_group_id', ''),
1001
+ 'dhcp_relay_group_name': network_range.get('dhcp_settings', {}).get('relay_group_name', ''),
1002
+ 'dhcp_microsegmentation': str(network_range.get('dhcp_settings', {}).get('dhcp_microsegmentation', False)).upper()
1003
+ }
1004
+ rows.append(row)
1005
+
1006
+ # If still no rows, it means the site only has the default LAN interface (managed at parent level)
1007
+ # In this case, create an empty CSV file - no entries needed since default interface is handled in parent CSV
1008
+ # This is correct behavior: sites with only default LAN interfaces should have empty site-level CSV files
1009
+
1010
+ # Always create file now (removed the early return)
1011
+ # if not rows:
1012
+ # return None
1013
+
1014
+ with open(filepath, 'w', newline='', encoding='utf-8') as csvfile:
1015
+ writer = csv.DictWriter(csvfile, fieldnames=headers)
1016
+ writer.writeheader()
1017
+ writer.writerows(rows)
1018
+
1019
+ if hasattr(args, 'verbose') and args.verbose:
1020
+ print(f"Exported {len(rows)} network ranges for site '{site_name}' to {filepath}")
1021
+
1022
+ return filepath
1023
+
1024
+
393
1025
  ##########################################################################
394
1026
  ########################### Helper functions #############################
395
1027
  ##########################################################################