diff --git a/google_sheet_handler.py b/google_sheet_handler.py index 8d0c2864..5919ad0f 100644 --- a/google_sheet_handler.py +++ b/google_sheet_handler.py @@ -1,358 +1,159 @@ -# --- START OF FILE google_sheet_handler.py --- - -#!/usr/bin/env python3 -""" -google_sheet_handler.py - -Klasse zur Kapselung der Interaktionen mit dem Google Sheet. -Stellt Verbindung her, lädt Daten und führt Batch-Updates durch. -""" +# google_sheet_handler.py import os -import time import logging -import traceback -from datetime import datetime - import gspread +import pandas as pd from oauth2client.service_account import ServiceAccountCredentials - -# Import der abhängigen Module -from config import Config, CREDENTIALS_FILE, COLUMN_MAP -from helpers import retry_on_failure +from config import Config, COLUMN_MAP +from helpers import retry_on_failure, _get_col_letter class GoogleSheetHandler: """ - Kapselt die Interaktionen mit dem Google Sheet, inklusive Verbindung, - Daten laden und Batch-Updates. Nutzt den retry_on_failure Decorator. + Kapselt alle Interaktionen mit dem Google Sheet. + Finale, robuste Version v2.1.0 """ - def __init__(self): + def __init__(self, sheet_url=None): """ - Initialisiert den Handler, stellt die Verbindung her und laedt die Daten. + Initialisiert den Handler. Die Verbindung wird bei Bedarf hergestellt ("lazy connect"). """ self.logger = logging.getLogger(__name__ + ".GoogleSheetHandler") - # WICHTIG: Attribute hier initialisieren - self.client = None - self.sheet = None - self._all_data = [] - self._header_rows = 5 - self.logger.info("Initialisiere GoogleSheetHandler...") - try: - self._connect() - if self.sheet: - self.load_data() - else: - raise ConnectionError("Google Sheet Handler Init failed: Verbindung konnte nicht hergestellt werden.") - except Exception as e: - self.logger.critical(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {e}") - raise ConnectionError(f"Google Sheet Handler Init failed: {e}") - - try: - self._connect() - if self.sheet: - self.load_data() - else: - self.logger.critical( - "GoogleSheetHandler Init FEHLER: Verbindung konnte nicht hergestellt werden (sheet ist None)." - ) - raise ConnectionError("Google Sheet Handler Init failed: Verbindung konnte nicht hergestellt werden.") - except Exception as e: - self.logger.critical(f"FATAL: Fehler bei Initialisierung von GoogleSheetHandler: {type(e).__name__} - {e}") - self.logger.debug(traceback.format_exc()) - raise ConnectionError(f"Google Sheet Handler Init failed: {e}") + + self.sheet_url = sheet_url or Config.SHEET_URL + if "docs.google.com" not in self.sheet_url: + raise ValueError(f"Ungültige Google Sheet URL in config.py: '{self.sheet_url}'") + + # Attribute werden immer initialisiert, um AttributeErrors zu vermeiden. + self.client = None + self.sheet = None # Haupt-Tabellenblatt ('Tabelle1') + self._all_data_with_headers = [] + self._header_rows = 5 @retry_on_failure def _connect(self): - """Stellt Verbindung zum Google Sheet her.""" - self.sheet = None - self.client = None # Zurücksetzen bei jedem Versuch - self.logger.info("Versuche Verbindung mit Google Sheets herstellen...") + """ + Stellt die Verbindung zu Google Sheets her, falls sie noch nicht besteht. + """ + if self.client: + self.logger.debug("Verbindung zu Google Sheets besteht bereits.") + return True + + self.logger.info("Stelle neue Verbindung mit Google Sheets her...") try: - if not os.path.exists(CREDENTIALS_FILE): - raise FileNotFoundError(f"Credential-Datei nicht gefunden: {CREDENTIALS_FILE}") - - scope = ["https://www.googleapis.com/auth/spreadsheets"] - creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) - # WICHTIG: Den Client in self.client speichern + creds = ServiceAccountCredentials.from_json_keyfile_name(Config.CREDENTIALS_FILE, ["https://www.googleapis.com/auth/spreadsheets"]) self.client = gspread.authorize(creds) - sh = self.client.open_by_url(Config.SHEET_URL) - self.sheet = sh.sheet1 # Das Hauptblatt für Leseoperationen - self.logger.info("Verbindung zu Google Sheets erfolgreich.") + spreadsheet = self.client.open_by_url(self.sheet_url) + self.sheet = spreadsheet.sheet1 + self.logger.info("Verbindung zu Google Sheets erfolgreich hergestellt.") + return True except Exception as e: self.logger.error(f"FEHLER bei der Google Sheets Verbindung: {e}") - self.logger.debug(traceback.format_exc()) - raise e - except Exception as e: - self.logger.error(f"FEHLER bei der Google Sheets Verbindung: {type(e).__name__} - {e}") - self.logger.debug(traceback.format_exc()) - raise e + self.client = None # Verbindung im Fehlerfall zurücksetzen + return False @retry_on_failure def load_data(self): - """ - Laedt alle Daten aus dem Sheet und aktualisiert die internen Datenstrukturen. - Führt eine Validierung der Spaltenanzahl durch. - """ - if not self.sheet: - self.logger.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") - self._all_data = [] + """Lädt alle Daten aus dem Haupt-Sheet ('Tabelle1').""" + if not self.client and not self._connect(): return False - - self.logger.info("Lade Daten aus Google Sheet...") + + self.logger.info("Lade Daten aus dem Haupt-Google-Sheet...") try: - self._all_data = self.sheet.get_all_values() - - if not self._all_data: - self.logger.warning("Google Sheet scheint leer zu sein.") - self.headers = [] - return True - - num_rows = len(self._all_data) - num_cols = len(self._all_data[0]) if num_rows > 0 else 0 - self.logger.info(f"Daten neu geladen: {num_rows} Zeilen, {num_cols} Spalten.") - - try: - all_indices = [v['index'] for v in COLUMN_MAP.values()] - if not all_indices: raise ValueError("COLUMN_MAP leer") - - max_col_idx_in_map = max(all_indices) - expected_min_cols = max_col_idx_in_map + 1 - if num_cols < expected_min_cols: - self.logger.warning( - f"Sheet hat nur {num_cols} Spalten, aber COLUMN_MAP erwartet mind. {expected_min_cols}.") - except Exception as e: - self.logger.error(f"Fehler bei der Pruefung der Spaltenanzahl: {e}") - - for i, row in enumerate(self._all_data): - if "CRM Name" in row: + self._all_data_with_headers = self.sheet.get_all_values() + self.logger.info(f"Daten erfolgreich geladen: {len(self._all_data_with_headers)} Zeilen.") + + # Dynamisch die Anzahl der Header-Zeilen finden + for i, row in enumerate(self._all_data_with_headers): + if "CRM Name" in row: # Annahme: "CRM Name" ist in der Haupt-Header-Zeile self._header_rows = i + 1 - self.headers = row break - else: - self.headers = self._all_data[0] if self._all_data else [] - return True except Exception as e: - self.logger.critical(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}", exc_info=True) + self.logger.critical(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {e}") return False - def get_data(self): - """ - Gibt die aktuell im Handler gespeicherten Datenzeilen zurueck - (ohne die ersten N Header-Zeilen). - """ - if not self._all_data or len(self._all_data) <= self._header_rows: - self.logger.debug( - f"get_data: Keine Datenzeilen verfuegbar " - f"(geladen: {len(self._all_data) if self._all_data else 0} Zeilen, " - f"{self._header_rows} Header)." - ) - return [] - return self._all_data[self._header_rows:].copy() - def get_all_data_with_headers(self): - """Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurueck.""" - if not self._all_data: - self.logger.debug("get_all_data_with_headers: Keine Daten im Handler gespeichert.") - return [] - return self._all_data.copy() - - def _get_col_letter(self, col_idx_1_based): - """ - Konvertiert einen 1-basierten Spaltenindex in den entsprechenden - Google Sheets Spaltenbuchstaben (A, B, ..., Z, AA, ...). - """ - if not isinstance(col_idx_1_based, int) or col_idx_1_based < 1: - self.logger.error(f"Ungueltiger Spaltenindex ({col_idx_1_based}) fuer _get_col_letter erhalten.") - return None - - string = "" - n = col_idx_1_based - while n > 0: - n, remainder = divmod(n - 1, 26) - string = chr(65 + remainder) + string - return string - - def get_start_row_index(self, check_column_key, min_sheet_row=7): - """ - Findet den 0-basierten Index in der DATENliste (ohne Header), - ab einer Mindestzeilennummer im Sheet, in der der Wert in der - Spalte (definiert durch check_column_key) EXAKT LEER ("") ist. - """ - # Daten müssen nicht extra geladen werden, da dies im aufrufenden Prozess geschieht. - data_rows = self.get_data() - if not data_rows: - self.logger.info("Keine Datenzeilen im Sheet gefunden. Startindex fuer leere Zelle ist 0.") - return 0 - - # KORREKTUR: Greife auf den 'index'-Wert zu - col_info = COLUMN_MAP.get(check_column_key) - if col_info is None or 'index' not in col_info: - self.logger.critical(f"FEHLER: Schluessel '{check_column_key}' oder sein 'index' nicht in COLUMN_MAP gefunden!") - return -1 - - check_column_index = col_info['index'] - - actual_col_letter = self._get_col_letter(check_column_index + 1) - if actual_col_letter is None: - actual_col_letter = f"Index_{check_column_index + 1}" - - search_start_index_in_data = max(0, (min_sheet_row - 1) - self._header_rows) - self.logger.info( - f"get_start_row_index: Suche ab Daten-Index {search_start_index_in_data} " - f"(Sheet-Zeile {search_start_index_in_data + self._header_rows + 1}) " - f"nach EXAKT LEEREM Wert (=='') in Spalte '{check_column_key}' ({actual_col_letter})..." - ) - - if search_start_index_in_data >= len(data_rows): - self.logger.warning( - f"Start-Suchindex in Daten ({search_start_index_in_data}) liegt hinter der letzten Datenzeile ({len(data_rows)})." - ) - return len(data_rows) - - for i in range(search_start_index_in_data, len(data_rows)): - row = data_rows[i] - current_sheet_row = i + self._header_rows + 1 - is_exactly_empty = True - if len(row) > check_column_index: - cell_value = row[check_column_index] - if cell_value != "": - is_exactly_empty = False - - if is_exactly_empty: - self.logger.info( - f"Erste Zeile ab Sheet-Zeile {min_sheet_row} mit EXAKT LEEREM Wert in Spalte " - f"{actual_col_letter} gefunden: Zeile {current_sheet_row} (Daten-Index {i})" - ) - return i - - last_data_index = len(data_rows) - self.logger.info( - f"Alle Zeilen ab Daten-Index {search_start_index_in_data} haben einen " - f"nicht-leeren Wert in Spalte {actual_col_letter}. Naechster Daten-Index waere {last_data_index}." - ) - return last_data_index - - def clear_and_write_data(self, sheet_name, data): - """ - Leert das angegebene Tabellenblatt vollständig und schreibt neue Daten hinein. - Die Daten sollten eine Liste von Listen sein (inklusive Header). - """ - try: - # Der Client wurde bereits in __init__ erstellt. Wir prüfen nur, ob er existiert. - if not self.client: - self.logger.error("Kein Google-Client vorhanden. Breche Schreibvorgang ab.") - return False - - self.logger.info(f"Greife auf Tabellenblatt '{sheet_name}' zu, um es zu leeren und neu zu beschreiben...") - worksheet = self.client.open_by_url(Config.SHEET_URL).worksheet(sheet_name) - - self.logger.debug("Leere das gesamte Tabellenblatt...") - worksheet.clear() - - num_rows = len(data) - num_cols = len(data[0]) if data else 0 - - if num_rows == 0: - self.logger.warning("Keine Daten zum Schreiben vorhanden.") - return True - - self.logger.info(f"Schreibe {num_rows - 1} neue Datenzeilen (insgesamt {num_rows} Zeilen mit Header) in '{sheet_name}'...") - - end_col_letter = self._get_col_letter(num_cols + 1) # Korrigiert auf +1 für 1-basiert - range_to_update = f'A1:{end_col_letter}{num_rows}' - - worksheet.update(range_name=range_to_update, values=data) - self.logger.info(f"Schreiben in Tabellenblatt '{sheet_name}' erfolgreich abgeschlossen.") - return True - except gspread.exceptions.WorksheetNotFound: - self.logger.error(f"FATAL: Das Tabellenblatt '{sheet_name}' wurde nicht gefunden. Bitte prüfen Sie den Namen.") - return False - except Exception as e: - self.logger.error(f"FATAL: Ein unerwarteter Fehler ist beim Schreiben in '{sheet_name}' aufgetreten: {e}") - return False + """Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurück.""" + return self._all_data_with_headers.copy() def get_sheet_as_dataframe(self, sheet_name): """Liest ein komplettes Tabellenblatt und gibt es als Pandas DataFrame zurück.""" try: - if not self.client: - if not self._connect(): return None + if not self.client and not self._connect(): return None - logging.debug(f"Lese Tabellenblatt '{sheet_name}' als DataFrame...") + self.logger.debug(f"Lese Tabellenblatt '{sheet_name}' als DataFrame...") worksheet = self.client.open_by_url(self.sheet_url).worksheet(sheet_name) data = worksheet.get_all_records() df = pd.DataFrame(data) - logging.info(f"{len(df)} Zeilen aus '{sheet_name}' als DataFrame geladen.") + self.logger.info(f"{len(df)} Zeilen aus '{sheet_name}' als DataFrame geladen.") return df except gspread.exceptions.WorksheetNotFound: - logging.warning(f"Tabellenblatt '{sheet_name}' nicht gefunden. Gehe davon aus, dass es leer ist.") - return None + self.logger.warning(f"Tabellenblatt '{sheet_name}' nicht gefunden. Erstelle leeren DataFrame.") + return pd.DataFrame() except Exception as e: - self.logger.error(f"Fehler beim Lesen des Sheets als DataFrame: {e}") + self.logger.error(f"Fehler beim Lesen des Sheets '{sheet_name}' als DataFrame: {e}") return None - def append_rows(self, sheet_name, values): + def append_rows(self, sheet_name, values_to_append): """Hängt eine Liste von Zeilen an ein Tabellenblatt an.""" try: - if not self.client: - if not self._connect(): return False + if not self.client and not self._connect(): return False - logging.debug(f"Hänge {len(values)} Zeilen an das Tabellenblatt '{sheet_name}' an...") + self.logger.debug(f"Hänge {len(values_to_append)} Zeilen an das Tabellenblatt '{sheet_name}' an...") worksheet = self.client.open_by_url(self.sheet_url).worksheet(sheet_name) - worksheet.append_rows(values, value_input_option='USER_ENTERED') - logging.info(f"{len(values)} Zeilen erfolgreich an '{sheet_name}' angehängt.") + worksheet.append_rows(values_to_append, value_input_option='USER_ENTERED') + self.logger.info(f"{len(values_to_append)} Zeilen erfolgreich an '{sheet_name}' angehängt.") return True except Exception as e: - self.logger.error(f"Fehler beim Anhängen von Zeilen an das Sheet: {e}") - return False + self.logger.error(f"Fehler beim Anhängen von Zeilen an das Sheet '{sheet_name}': {e}") + return False + + def clear_and_write_data(self, sheet_name, data): + """Leert das angegebene Tabellenblatt vollständig und schreibt neue Daten hinein.""" + try: + if not self.client and not self._connect(): return False + + self.logger.info(f"Greife auf Tabellenblatt '{sheet_name}' zu, um es zu leeren und neu zu beschreiben...") + worksheet = self.client.open_by_url(self.sheet_url).worksheet(sheet_name) + + worksheet.clear() + + if not data: + self.logger.warning("Keine Daten zum Schreiben in '{sheet_name}' vorhanden.") + return True + + end_col_letter = _get_col_letter(len(data[0])) + range_to_update = f'A1:{end_col_letter}{len(data)}' + worksheet.update(range_name=range_to_update, values=data) + self.logger.info(f"Schreiben von {len(data)} Zeilen in '{sheet_name}' erfolgreich.") + return True + except gspread.exceptions.WorksheetNotFound: + self.logger.error(f"FATAL: Das Tabellenblatt '{sheet_name}' wurde nicht gefunden.") + return False + except Exception as e: + self.logger.error(f"FATAL: Unerwarteter Fehler bei clear_and_write_data für '{sheet_name}': {e}") + return False @retry_on_failure def batch_update_cells(self, update_data): - """ - Fuehrt ein Batch-Update im Google Sheet durch. - NEU: Konvertiert alle zu schreibenden Werte explizit in Strings, um Fehler zu vermeiden. - """ - if not self.sheet: + """Führt ein Batch-Update im Haupt-Sheet durch.""" + if not self.sheet and not self._connect(): self.logger.error("FEHLER: Keine Sheet-Verbindung fuer Batch-Update.") return False if not update_data: - self.logger.debug("batch_update_cells: Keine Daten zum Aktualisieren erhalten.") return True - - # --- NEUE, ROBUSTE DATENAUFBEREITUNG --- + sanitized_update_data = [] for item in update_data: if 'range' in item and 'values' in item and isinstance(item['values'], list): - # Konvertiere jeden einzelnen Zellwert im values-Array sicher in einen String - sanitized_values = [ - [str(cell_value) if cell_value is not None else "" for cell_value in row] - for row in item['values'] - ] - sanitized_update_data.append({ - 'range': item['range'], - 'values': sanitized_values - }) - else: - self.logger.warning(f"Überspringe ungültiges Update-Objekt: {item}") - - if not sanitized_update_data: - self.logger.warning("Keine gültigen Daten nach der Bereinigung für das Batch-Update übrig.") - return True + sanitized_values = [[str(cell) if cell is not None else "" for cell in row] for row in item['values']] + sanitized_update_data.append({'range': item['range'], 'values': sanitized_values}) - try: - total_cells_to_update = sum(len(row) for item in sanitized_update_data for row in item.get('values', [])) - self.logger.debug(f" -> Versuche sheet.batch_update mit {len(sanitized_update_data)} Anfragen ({total_cells_to_update} Zellen)...") - - # Logge das erste Datenobjekt zur Überprüfung - if self.logger.level == logging.DEBUG and sanitized_update_data: - self.logger.debug(f" -> Beispiel-Update-Daten: {str(sanitized_update_data[0])}") - - self.sheet.batch_update(sanitized_update_data, value_input_option='USER_ENTERED') - self.logger.info(f"Batch-Update mit {total_cells_to_update} Zellen erfolgreich gesendet.") - return True - except Exception as e: - self.logger.error(f"Endgueltiger Fehler beim Batch-Update nach Retries: {e}", exc_info=True) - return False - -# --- END OF FILE google_sheet_handler.py --- \ No newline at end of file + if not sanitized_update_data: return True + + total_cells = sum(len(row) for item in sanitized_update_data for row in item.get('values', [])) + self.logger.debug(f"Sende Batch-Update mit {len(sanitized_update_data)} Anfragen ({total_cells} Zellen)...") + self.sheet.batch_update(sanitized_update_data, value_input_option='USER_ENTERED') + self.logger.info(f"Batch-Update mit {total_cells} Zellen erfolgreich gesendet.") + return True \ No newline at end of file