Feb27 2026: Implement backup/restore tool
This commit is contained in:
+615
@@ -336,6 +336,16 @@ class QBOExcelSyncApp:
|
||||
lookup_menu.add_command(label="Payment Methods...", command=lambda: self._show_qb_lookup('paymentmethod'))
|
||||
lookup_menu.add_command(label="Employees...", command=lambda: self._show_qb_lookup('employee'))
|
||||
|
||||
tools_menu.add_separator()
|
||||
|
||||
# QBO Data Backup/Restore submenu
|
||||
backup_menu = tk.Menu(tools_menu, tearoff=0)
|
||||
tools_menu.add_cascade(label="QBO Data Backup/Restore", menu=backup_menu)
|
||||
backup_menu.add_command(label="Backup Company Data...", command=self._backup_qbo_data)
|
||||
backup_menu.add_command(label="Restore Company Data...", command=self._restore_qbo_data)
|
||||
backup_menu.add_separator()
|
||||
backup_menu.add_command(label="Export to Excel...", command=self._export_qbo_to_excel)
|
||||
|
||||
tools_menu.add_separator()
|
||||
tools_menu.add_command(label="View Logs...", command=self._show_logs)
|
||||
|
||||
@@ -4494,6 +4504,611 @@ except Exception as e:
|
||||
|
||||
ttk.Button(dialog, text="Save", command=save_settings).pack(pady=10)
|
||||
|
||||
# =========================================================================
|
||||
# QBO Data Backup/Restore Methods
|
||||
# =========================================================================
|
||||
|
||||
def _backup_qbo_data(self):
|
||||
"""Backup QuickBooks Online company data to a JSON file."""
|
||||
if not self.is_connected:
|
||||
messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.")
|
||||
return
|
||||
|
||||
if self.connection_type != 'qbo':
|
||||
messagebox.showwarning("QBO Only", "Data backup is currently only supported for QuickBooks Online.")
|
||||
return
|
||||
|
||||
# Show backup options dialog
|
||||
dialog = tk.Toplevel(self.root)
|
||||
dialog.title("Backup QBO Company Data")
|
||||
dialog.geometry("500x450")
|
||||
dialog.transient(self.root)
|
||||
dialog.grab_set()
|
||||
|
||||
ttk.Label(dialog, text="Select data types to backup:", font=('Segoe UI', 11, 'bold')).pack(pady=(15, 10))
|
||||
|
||||
# Data type checkboxes
|
||||
data_types = [
|
||||
('accounts', 'Chart of Accounts', True),
|
||||
('customers', 'Customers', True),
|
||||
('vendors', 'Vendors', True),
|
||||
('items', 'Items/Products & Services', True),
|
||||
('employees', 'Employees', False),
|
||||
('classes', 'Classes', False),
|
||||
('departments', 'Departments/Locations', False),
|
||||
('terms', 'Payment Terms', False),
|
||||
('paymentmethods', 'Payment Methods', False),
|
||||
]
|
||||
|
||||
backup_vars = {}
|
||||
check_frame = ttk.Frame(dialog)
|
||||
check_frame.pack(fill=tk.X, padx=30, pady=10)
|
||||
|
||||
for i, (key, label, default) in enumerate(data_types):
|
||||
var = tk.BooleanVar(value=default)
|
||||
backup_vars[key] = var
|
||||
cb = ttk.Checkbutton(check_frame, text=label, variable=var)
|
||||
cb.grid(row=i // 2, column=i % 2, sticky='w', padx=10, pady=3)
|
||||
|
||||
# Select all / none buttons
|
||||
btn_frame = ttk.Frame(dialog)
|
||||
btn_frame.pack(fill=tk.X, padx=30)
|
||||
|
||||
def select_all():
|
||||
for var in backup_vars.values():
|
||||
var.set(True)
|
||||
|
||||
def select_none():
|
||||
for var in backup_vars.values():
|
||||
var.set(False)
|
||||
|
||||
ttk.Button(btn_frame, text="Select All", command=select_all, width=12).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(btn_frame, text="Select None", command=select_none, width=12).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# Progress
|
||||
progress_frame = ttk.Frame(dialog)
|
||||
progress_frame.pack(fill=tk.X, padx=30, pady=20)
|
||||
|
||||
progress_label = ttk.Label(progress_frame, text="Ready to backup")
|
||||
progress_label.pack(anchor=tk.W)
|
||||
|
||||
progress_bar = ttk.Progressbar(progress_frame, mode='determinate', length=400)
|
||||
progress_bar.pack(fill=tk.X, pady=5)
|
||||
|
||||
status_label = ttk.Label(progress_frame, text="", foreground='gray')
|
||||
status_label.pack(anchor=tk.W)
|
||||
|
||||
def start_backup():
|
||||
selected = [k for k, v in backup_vars.items() if v.get()]
|
||||
if not selected:
|
||||
messagebox.showwarning("No Selection", "Please select at least one data type to backup.")
|
||||
return
|
||||
|
||||
# Get save location
|
||||
safe_company = "".join(c for c in self.company_name if c.isalnum() or c in " -_").strip()
|
||||
file_path = filedialog.asksaveasfilename(
|
||||
title="Save Backup File",
|
||||
defaultextension=".json",
|
||||
filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")],
|
||||
initialfile=f"qbo_backup_{safe_company}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
# Disable buttons during backup
|
||||
backup_btn.config(state=tk.DISABLED)
|
||||
|
||||
def backup_thread():
|
||||
try:
|
||||
backup_data = {
|
||||
'backup_info': {
|
||||
'version': '1.0',
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'company_name': self.company_name,
|
||||
'realm_id': getattr(self.qbo_client, 'realm_id', ''),
|
||||
'data_types': selected
|
||||
},
|
||||
'data': {}
|
||||
}
|
||||
|
||||
total_steps = len(selected)
|
||||
current_step = 0
|
||||
|
||||
entity_map = {
|
||||
'accounts': ('Account', 'accounts'),
|
||||
'customers': ('Customer', 'customers'),
|
||||
'vendors': ('Vendor', 'vendors'),
|
||||
'items': ('Item', 'items'),
|
||||
'employees': ('Employee', 'employees'),
|
||||
'classes': ('Class', 'classes'),
|
||||
'departments': ('Department', 'departments'),
|
||||
'terms': ('Term', 'terms'),
|
||||
'paymentmethods': ('PaymentMethod', 'paymentmethods'),
|
||||
}
|
||||
|
||||
for data_type in selected:
|
||||
current_step += 1
|
||||
progress = int((current_step / total_steps) * 100)
|
||||
|
||||
query_name, key = entity_map.get(data_type, (data_type.title(), data_type))
|
||||
|
||||
dialog.after(0, lambda p=progress, t=data_type, cs=current_step, ts=total_steps: (
|
||||
progress_bar.configure(value=p),
|
||||
progress_label.configure(text=f"Backing up {t}..."),
|
||||
status_label.configure(text=f"Step {cs} of {ts}")
|
||||
))
|
||||
|
||||
try:
|
||||
# Query all entities of this type using _make_request
|
||||
query = f"SELECT * FROM {query_name} MAXRESULTS 1000"
|
||||
response = self.qbo_client._make_request('GET', 'query', params={'query': query})
|
||||
|
||||
if response and 'QueryResponse' in response:
|
||||
entities = response['QueryResponse'].get(query_name, [])
|
||||
else:
|
||||
entities = []
|
||||
|
||||
backup_data['data'][key] = entities
|
||||
logger.info(f"[BACKUP] Backed up {len(entities)} {key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[BACKUP] Failed to backup {key}: {e}")
|
||||
backup_data['data'][key] = []
|
||||
|
||||
# Save to file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(backup_data, f, indent=2, default=str)
|
||||
|
||||
# Calculate totals
|
||||
total_records = sum(len(v) for v in backup_data['data'].values())
|
||||
|
||||
dialog.after(0, lambda: (
|
||||
progress_bar.configure(value=100),
|
||||
progress_label.configure(text="Backup completed!"),
|
||||
status_label.configure(text=f"Total: {total_records} records saved"),
|
||||
backup_btn.configure(state=tk.NORMAL),
|
||||
messagebox.showinfo("Backup Complete",
|
||||
f"Successfully backed up {total_records} records from {self.company_name}.\n\n"
|
||||
f"File saved to:\n{file_path}")
|
||||
))
|
||||
|
||||
logger.info(f"[BACKUP] Completed backup to {file_path} - {total_records} total records")
|
||||
|
||||
except Exception as e:
|
||||
dialog.after(0, lambda: (
|
||||
progress_label.configure(text="Backup failed!"),
|
||||
status_label.configure(text=str(e)),
|
||||
backup_btn.configure(state=tk.NORMAL),
|
||||
messagebox.showerror("Backup Failed", f"Error during backup: {str(e)}")
|
||||
))
|
||||
logger.error(f"[BACKUP] Failed: {e}")
|
||||
|
||||
threading.Thread(target=backup_thread, daemon=True).start()
|
||||
|
||||
# Buttons
|
||||
action_frame = ttk.Frame(dialog)
|
||||
action_frame.pack(fill=tk.X, padx=30, pady=15)
|
||||
|
||||
backup_btn = ttk.Button(action_frame, text="Start Backup", command=start_backup, width=15)
|
||||
backup_btn.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
ttk.Button(action_frame, text="Close", command=dialog.destroy, width=10).pack(side=tk.RIGHT, padx=5)
|
||||
|
||||
def _restore_qbo_data(self):
|
||||
"""Restore QuickBooks Online company data from a backup file."""
|
||||
if not self.is_connected:
|
||||
messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.")
|
||||
return
|
||||
|
||||
if self.connection_type != 'qbo':
|
||||
messagebox.showwarning("QBO Only", "Data restore is currently only supported for QuickBooks Online.")
|
||||
return
|
||||
|
||||
# Select backup file
|
||||
file_path = filedialog.askopenfilename(
|
||||
title="Select Backup File",
|
||||
filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")]
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
backup_data = json.load(f)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to read backup file: {e}")
|
||||
return
|
||||
|
||||
# Validate backup file
|
||||
if 'backup_info' not in backup_data or 'data' not in backup_data:
|
||||
messagebox.showerror("Invalid File", "This doesn't appear to be a valid QBO backup file.")
|
||||
return
|
||||
|
||||
backup_info = backup_data['backup_info']
|
||||
|
||||
# Show restore dialog
|
||||
dialog = tk.Toplevel(self.root)
|
||||
dialog.title("Restore QBO Company Data")
|
||||
dialog.geometry("550x500")
|
||||
dialog.transient(self.root)
|
||||
dialog.grab_set()
|
||||
|
||||
# Backup info
|
||||
info_frame = ttk.LabelFrame(dialog, text="Backup Information", padding=10)
|
||||
info_frame.pack(fill=tk.X, padx=15, pady=10)
|
||||
|
||||
ttk.Label(info_frame, text=f"Company: {backup_info.get('company_name', 'Unknown')}").pack(anchor=tk.W)
|
||||
ttk.Label(info_frame, text=f"Created: {backup_info.get('created_at', 'Unknown')[:19]}").pack(anchor=tk.W)
|
||||
|
||||
# Warning
|
||||
warning_frame = ttk.Frame(dialog)
|
||||
warning_frame.pack(fill=tk.X, padx=15, pady=5)
|
||||
ttk.Label(warning_frame, text="⚠️ Warning: Restoring will create new records. Existing records won't be modified.",
|
||||
foreground='orange', wraplength=500).pack(anchor=tk.W)
|
||||
|
||||
# Data to restore
|
||||
ttk.Label(dialog, text="Select data types to restore:", font=('Segoe UI', 10, 'bold')).pack(padx=15, pady=(10, 5), anchor=tk.W)
|
||||
|
||||
restore_vars = {}
|
||||
data_frame = ttk.Frame(dialog)
|
||||
data_frame.pack(fill=tk.X, padx=30, pady=5)
|
||||
|
||||
row = 0
|
||||
for key, records in backup_data['data'].items():
|
||||
count = len(records) if records else 0
|
||||
var = tk.BooleanVar(value=count > 0)
|
||||
restore_vars[key] = var
|
||||
|
||||
cb = ttk.Checkbutton(data_frame, text=f"{key.title()} ({count} records)", variable=var,
|
||||
state=tk.NORMAL if count > 0 else tk.DISABLED)
|
||||
cb.grid(row=row // 2, column=row % 2, sticky='w', padx=10, pady=2)
|
||||
row += 1
|
||||
|
||||
# Progress
|
||||
progress_frame = ttk.Frame(dialog)
|
||||
progress_frame.pack(fill=tk.X, padx=15, pady=15)
|
||||
|
||||
progress_label = ttk.Label(progress_frame, text="Ready to restore")
|
||||
progress_label.pack(anchor=tk.W)
|
||||
|
||||
progress_bar = ttk.Progressbar(progress_frame, mode='determinate', length=480)
|
||||
progress_bar.pack(fill=tk.X, pady=5)
|
||||
|
||||
status_label = ttk.Label(progress_frame, text="", foreground='gray')
|
||||
status_label.pack(anchor=tk.W)
|
||||
|
||||
# Results text
|
||||
results_frame = ttk.LabelFrame(dialog, text="Results", padding=5)
|
||||
results_frame.pack(fill=tk.BOTH, expand=True, padx=15, pady=5)
|
||||
|
||||
results_text = scrolledtext.ScrolledText(results_frame, height=6, font=('Consolas', 9))
|
||||
results_text.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
def start_restore():
|
||||
selected = [k for k, v in restore_vars.items() if v.get()]
|
||||
if not selected:
|
||||
messagebox.showwarning("No Selection", "Please select at least one data type to restore.")
|
||||
return
|
||||
|
||||
# Confirm restore
|
||||
if not messagebox.askyesno("Confirm Restore",
|
||||
f"This will create new records in {self.company_name}.\n\n"
|
||||
f"Data types: {', '.join(selected)}\n\n"
|
||||
"Continue?"):
|
||||
return
|
||||
|
||||
restore_btn.config(state=tk.DISABLED)
|
||||
|
||||
def restore_thread():
|
||||
results = {'success': 0, 'failed': 0, 'skipped': 0}
|
||||
|
||||
try:
|
||||
total_records = sum(len(backup_data['data'].get(k, [])) for k in selected)
|
||||
processed = 0
|
||||
|
||||
for data_type in selected:
|
||||
records = backup_data['data'].get(data_type, [])
|
||||
|
||||
dialog.after(0, lambda t=data_type: (
|
||||
progress_label.configure(text=f"Restoring {t}..."),
|
||||
results_text.insert(tk.END, f"\n--- Restoring {t} ---\n"),
|
||||
results_text.see(tk.END)
|
||||
))
|
||||
|
||||
for record in records:
|
||||
processed += 1
|
||||
progress = int((processed / total_records) * 100) if total_records > 0 else 100
|
||||
|
||||
dialog.after(0, lambda p=progress, c=processed, t=total_records: (
|
||||
progress_bar.configure(value=p),
|
||||
status_label.configure(text=f"Processing {c} of {t}")
|
||||
))
|
||||
|
||||
try:
|
||||
# Prepare record for creation (remove read-only fields)
|
||||
create_data = self._prepare_record_for_create(data_type, record)
|
||||
|
||||
if create_data:
|
||||
# Create the record using the appropriate method
|
||||
entity_name = data_type.rstrip('s').title()
|
||||
if data_type == 'classes':
|
||||
entity_name = 'Class'
|
||||
elif data_type == 'paymentmethods':
|
||||
entity_name = 'PaymentMethod'
|
||||
|
||||
# Use _make_request to create the entity
|
||||
self.qbo_client._make_request('POST', entity_name.lower(), json=create_data)
|
||||
results['success'] += 1
|
||||
|
||||
record_name = create_data.get('Name', create_data.get('DisplayName', 'Unknown'))
|
||||
dialog.after(0, lambda n=record_name: (
|
||||
results_text.insert(tk.END, f" ✓ Created: {n}\n"),
|
||||
results_text.see(tk.END)
|
||||
))
|
||||
else:
|
||||
results['skipped'] += 1
|
||||
|
||||
except Exception as e:
|
||||
results['failed'] += 1
|
||||
error_msg = str(e)[:80]
|
||||
dialog.after(0, lambda em=error_msg: (
|
||||
results_text.insert(tk.END, f" ✗ Failed: {em}\n"),
|
||||
results_text.see(tk.END)
|
||||
))
|
||||
|
||||
# Complete
|
||||
dialog.after(0, lambda: (
|
||||
progress_bar.configure(value=100),
|
||||
progress_label.configure(text="Restore completed!"),
|
||||
status_label.configure(text=f"Success: {results['success']}, Failed: {results['failed']}, Skipped: {results['skipped']}"),
|
||||
restore_btn.configure(state=tk.NORMAL),
|
||||
results_text.insert(tk.END, f"\n=== RESTORE COMPLETE ===\n"
|
||||
f"Success: {results['success']}\n"
|
||||
f"Failed: {results['failed']}\n"
|
||||
f"Skipped: {results['skipped']}\n"),
|
||||
results_text.see(tk.END)
|
||||
))
|
||||
|
||||
logger.info(f"[RESTORE] Completed - Success: {results['success']}, Failed: {results['failed']}, Skipped: {results['skipped']}")
|
||||
|
||||
except Exception as e:
|
||||
dialog.after(0, lambda: (
|
||||
progress_label.configure(text="Restore failed!"),
|
||||
status_label.configure(text=str(e)),
|
||||
restore_btn.configure(state=tk.NORMAL),
|
||||
messagebox.showerror("Restore Failed", f"Error during restore: {str(e)}")
|
||||
))
|
||||
logger.error(f"[RESTORE] Failed: {e}")
|
||||
|
||||
threading.Thread(target=restore_thread, daemon=True).start()
|
||||
|
||||
# Buttons
|
||||
action_frame = ttk.Frame(dialog)
|
||||
action_frame.pack(fill=tk.X, padx=15, pady=10)
|
||||
|
||||
restore_btn = ttk.Button(action_frame, text="Start Restore", command=start_restore, width=15)
|
||||
restore_btn.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
ttk.Button(action_frame, text="Close", command=dialog.destroy, width=10).pack(side=tk.RIGHT, padx=5)
|
||||
|
||||
def _prepare_record_for_create(self, data_type: str, record: dict) -> dict:
|
||||
"""Prepare a backup record for creation by removing read-only fields."""
|
||||
if not record:
|
||||
return None
|
||||
|
||||
# Fields to always remove (read-only or system-generated)
|
||||
remove_fields = [
|
||||
'Id', 'SyncToken', 'MetaData', 'domain', 'sparse',
|
||||
'time', 'status', 'FullyQualifiedName', 'Level',
|
||||
'CurrentBalance', 'CurrentBalanceWithSubAccounts',
|
||||
'Balance', 'BalanceWithJobs', 'OpenBalanceDate',
|
||||
]
|
||||
|
||||
# Create a copy without read-only fields
|
||||
create_data = {}
|
||||
for key, value in record.items():
|
||||
if key not in remove_fields and value is not None:
|
||||
create_data[key] = value
|
||||
|
||||
# Handle specific entity types
|
||||
if data_type == 'accounts':
|
||||
# Remove sub-account reference if parent doesn't exist
|
||||
if 'ParentRef' in create_data:
|
||||
del create_data['ParentRef']
|
||||
create_data['SubAccount'] = False
|
||||
|
||||
elif data_type in ['customers', 'vendors']:
|
||||
# Remove parent reference for sub-customers/vendors
|
||||
if 'ParentRef' in create_data:
|
||||
del create_data['ParentRef']
|
||||
if 'Job' in create_data:
|
||||
create_data['Job'] = False
|
||||
|
||||
return create_data if create_data.get('Name') or create_data.get('DisplayName') else None
|
||||
|
||||
def _export_qbo_to_excel(self):
|
||||
"""Export QuickBooks Online data to Excel file."""
|
||||
if not self.is_connected:
|
||||
messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.")
|
||||
return
|
||||
|
||||
if self.connection_type != 'qbo':
|
||||
messagebox.showwarning("QBO Only", "Excel export is currently only supported for QuickBooks Online.")
|
||||
return
|
||||
|
||||
# Show export dialog
|
||||
dialog = tk.Toplevel(self.root)
|
||||
dialog.title("Export QBO Data to Excel")
|
||||
dialog.geometry("450x400")
|
||||
dialog.transient(self.root)
|
||||
dialog.grab_set()
|
||||
|
||||
ttk.Label(dialog, text="Select data types to export:", font=('Segoe UI', 11, 'bold')).pack(pady=(15, 10))
|
||||
|
||||
# Data type checkboxes
|
||||
data_types = [
|
||||
('accounts', 'Chart of Accounts'),
|
||||
('customers', 'Customers'),
|
||||
('vendors', 'Vendors'),
|
||||
('items', 'Items/Products & Services'),
|
||||
('employees', 'Employees'),
|
||||
('classes', 'Classes'),
|
||||
]
|
||||
|
||||
export_vars = {}
|
||||
check_frame = ttk.Frame(dialog)
|
||||
check_frame.pack(fill=tk.X, padx=30, pady=10)
|
||||
|
||||
for i, (key, label) in enumerate(data_types):
|
||||
var = tk.BooleanVar(value=i < 4) # First 4 selected by default
|
||||
export_vars[key] = var
|
||||
cb = ttk.Checkbutton(check_frame, text=label, variable=var)
|
||||
cb.grid(row=i // 2, column=i % 2, sticky='w', padx=10, pady=3)
|
||||
|
||||
# Progress
|
||||
progress_frame = ttk.Frame(dialog)
|
||||
progress_frame.pack(fill=tk.X, padx=30, pady=20)
|
||||
|
||||
progress_label = ttk.Label(progress_frame, text="Ready to export")
|
||||
progress_label.pack(anchor=tk.W)
|
||||
|
||||
progress_bar = ttk.Progressbar(progress_frame, mode='determinate', length=350)
|
||||
progress_bar.pack(fill=tk.X, pady=5)
|
||||
|
||||
def start_export():
|
||||
selected = [k for k, v in export_vars.items() if v.get()]
|
||||
if not selected:
|
||||
messagebox.showwarning("No Selection", "Please select at least one data type to export.")
|
||||
return
|
||||
|
||||
# Get save location
|
||||
safe_company = "".join(c for c in self.company_name if c.isalnum() or c in " -_").strip()
|
||||
file_path = filedialog.asksaveasfilename(
|
||||
title="Save Excel File",
|
||||
defaultextension=".xlsx",
|
||||
filetypes=[("Excel Files", "*.xlsx"), ("All Files", "*.*")],
|
||||
initialfile=f"qbo_export_{safe_company}_{datetime.now().strftime('%Y%m%d')}.xlsx"
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
export_btn.config(state=tk.DISABLED)
|
||||
|
||||
def export_thread():
|
||||
try:
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
wb.remove(wb.active) # Remove default sheet
|
||||
|
||||
entity_map = {
|
||||
'accounts': 'Account',
|
||||
'customers': 'Customer',
|
||||
'vendors': 'Vendor',
|
||||
'items': 'Item',
|
||||
'employees': 'Employee',
|
||||
'classes': 'Class',
|
||||
}
|
||||
|
||||
total_steps = len(selected)
|
||||
current_step = 0
|
||||
total_records = 0
|
||||
|
||||
for data_type in selected:
|
||||
current_step += 1
|
||||
progress = int((current_step / total_steps) * 100)
|
||||
|
||||
dialog.after(0, lambda p=progress, t=data_type: (
|
||||
progress_bar.configure(value=p),
|
||||
progress_label.configure(text=f"Exporting {t}...")
|
||||
))
|
||||
|
||||
try:
|
||||
query_name = entity_map.get(data_type, data_type.title())
|
||||
|
||||
# Query using _make_request
|
||||
query = f"SELECT * FROM {query_name} MAXRESULTS 1000"
|
||||
response = self.qbo_client._make_request('GET', 'query', params={'query': query})
|
||||
|
||||
if response and 'QueryResponse' in response:
|
||||
entities = response['QueryResponse'].get(query_name, [])
|
||||
else:
|
||||
entities = []
|
||||
|
||||
if entities:
|
||||
# Create worksheet
|
||||
ws = wb.create_sheet(title=data_type.title()[:31]) # Max 31 chars
|
||||
|
||||
# Get all unique keys from entities
|
||||
all_keys = set()
|
||||
for entity in entities:
|
||||
all_keys.update(entity.keys())
|
||||
|
||||
# Sort keys for consistent order
|
||||
headers = sorted(all_keys)
|
||||
|
||||
# Write headers
|
||||
for col, header in enumerate(headers, 1):
|
||||
ws.cell(row=1, column=col, value=header)
|
||||
|
||||
# Write data
|
||||
for row_idx, entity in enumerate(entities, 2):
|
||||
for col_idx, header in enumerate(headers, 1):
|
||||
value = entity.get(header, '')
|
||||
# Convert dicts/lists to string
|
||||
if isinstance(value, (dict, list)):
|
||||
value = json.dumps(value)
|
||||
ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
|
||||
total_records += len(entities)
|
||||
logger.info(f"[EXPORT] Exported {len(entities)} {data_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[EXPORT] Failed to export {data_type}: {e}")
|
||||
|
||||
# Save workbook
|
||||
wb.save(file_path)
|
||||
|
||||
dialog.after(0, lambda: (
|
||||
progress_bar.configure(value=100),
|
||||
progress_label.configure(text="Export completed!"),
|
||||
export_btn.configure(state=tk.NORMAL),
|
||||
messagebox.showinfo("Export Complete",
|
||||
f"Successfully exported {total_records} records to Excel.\n\n"
|
||||
f"File saved to:\n{file_path}")
|
||||
))
|
||||
|
||||
logger.info(f"[EXPORT] Completed Excel export to {file_path} - {total_records} total records")
|
||||
|
||||
except ImportError:
|
||||
dialog.after(0, lambda: (
|
||||
export_btn.configure(state=tk.NORMAL),
|
||||
messagebox.showerror("Missing Package",
|
||||
"openpyxl is required for Excel export.\n\n"
|
||||
"Please install it with:\n"
|
||||
" pip install openpyxl")
|
||||
))
|
||||
except Exception as e:
|
||||
dialog.after(0, lambda: (
|
||||
progress_label.configure(text="Export failed!"),
|
||||
export_btn.configure(state=tk.NORMAL),
|
||||
messagebox.showerror("Export Failed", f"Error during export: {str(e)}")
|
||||
))
|
||||
logger.error(f"[EXPORT] Failed: {e}")
|
||||
|
||||
threading.Thread(target=export_thread, daemon=True).start()
|
||||
|
||||
# Buttons
|
||||
action_frame = ttk.Frame(dialog)
|
||||
action_frame.pack(fill=tk.X, padx=30, pady=15)
|
||||
|
||||
export_btn = ttk.Button(action_frame, text="Export to Excel", command=start_export, width=15)
|
||||
export_btn.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
ttk.Button(action_frame, text="Close", command=dialog.destroy, width=10).pack(side=tk.RIGHT, padx=5)
|
||||
|
||||
def _show_logs(self):
|
||||
"""Show log viewer dialog."""
|
||||
dialog = tk.Toplevel(self.root)
|
||||
|
||||
Reference in New Issue
Block a user