# --- 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. """ import os import time import logging import traceback from datetime import datetime import gspread 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 class GoogleSheetHandler: """ Kapselt die Interaktionen mit dem Google Sheet, inklusive Verbindung, Daten laden und Batch-Updates. Nutzt den retry_on_failure Decorator. """ def __init__(self): """ Initialisiert den Handler, stellt die Verbindung her und laedt die Daten. """ 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}") @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...") 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 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.") 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 @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 = [] return False self.logger.info("Lade Daten aus 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._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) 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 @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: 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 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 ---