catocli 2.1.4__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.

@@ -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 ##
@@ -390,6 +409,617 @@ def export_socket_site_to_json(args, configuration):
390
409
  return [{"success": False, "error": str(e), "error_details": error_details}]
391
410
 
392
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
+
393
1023
  ##########################################################################
394
1024
  ########################### Helper functions #############################
395
1025
  ##########################################################################