Feb27 2026: Implement backup/restore tool

This commit is contained in:
2026-02-27 10:26:04 -05:00
parent a29135304d
commit 335488223d
+615
View File
@@ -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)