diff --git a/google_sheet_handler.py b/google_sheet_handler.py new file mode 100644 index 00000000..280c131f --- /dev/null +++ b/google_sheet_handler.py @@ -0,0 +1,243 @@ +# --- 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") + self.sheet = None + self.sheet_values = [] + self._header_rows = 5 # Annahme: Die ersten 5 Zeilen sind Header + + self.logger.info("Initialisiere GoogleSheetHandler...") + 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.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) + gc = gspread.authorize(creds) + sh = gc.open_by_url(Config.SHEET_URL) + self.sheet = sh.sheet1 + self.logger.info("Verbindung zu Google Sheets erfolgreich.") + 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 self.sheet_values. + """ + if not self.sheet: + self.logger.error("Fehler: Keine Sheet-Verbindung zum Laden der Daten.") + self.sheet_values = [] + return False + + self.logger.info("Lade Daten aus Google Sheet...") + try: + self.sheet_values = self.sheet.get_all_values() + + if not self.sheet_values: + self.logger.warning("Google Sheet scheint leer zu sein oder get_all_values() lieferte keine Daten.") + self.headers = [] + return True + + num_rows = len(self.sheet_values) + num_cols = len(self.sheet_values[0]) if num_rows > 0 else 0 + self.logger.info(f"Daten neu geladen: {num_rows} Zeilen, {num_cols} Spalten.") + + try: + max_col_idx_in_map = max(COLUMN_MAP.values()) + if num_cols <= max_col_idx_in_map: + self.logger.warning( + f"Geladenes Sheet hat {num_cols} Spalten, erwartet werden aber mindestens " + f"{max_col_idx_in_map + 1} basierend auf COLUMN_MAP." + ) + except ValueError: + self.logger.warning("COLUMN_MAP scheint leer zu sein. Kann Spaltenanzahl nicht pruefen.") + except Exception as e: + self.logger.error(f"Fehler bei der Pruefung der Spaltenanzahl gegen COLUMN_MAP: {e}") + + if num_rows > 0: + self.headers = self.sheet_values[0] + else: + self.headers = [] + + return True + except Exception as e: + self.logger.error(f"Allgemeiner Fehler beim Laden der Google Sheet Daten: {type(e).__name__} - {e}") + self.logger.debug(traceback.format_exc()) + raise e + + def get_data(self): + """ + Gibt die aktuell im Handler gespeicherten Datenzeilen zurueck + (ohne die ersten N Header-Zeilen). + """ + if not self.sheet_values or len(self.sheet_values) <= self._header_rows: + self.logger.debug( + f"get_data: Keine Datenzeilen verfuegbar " + f"(geladen: {len(self.sheet_values) if self.sheet_values else 0} Zeilen, " + f"{self._header_rows} Header)." + ) + return [] + return self.sheet_values[self._header_rows:].copy() + + def get_all_data_with_headers(self): + """Gibt alle aktuell im Handler gespeicherten Daten inklusive Header zurueck.""" + if not self.sheet_values: + self.logger.debug("get_all_data_with_headers: Keine Daten im Handler gespeichert.") + return [] + return self.sheet_values.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. + """ + if not self.load_data(): + self.logger.error("Fehler beim Laden der Daten fuer get_start_row_index.") + return -1 + + 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 + + check_column_index = COLUMN_MAP.get(check_column_key) + if check_column_index is None: + self.logger.critical(f"FEHLER: Schluessel '{check_column_key}' nicht in COLUMN_MAP gefunden!") + return -1 + + 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 + cell_value = "" + 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 + + @retry_on_failure + def batch_update_cells(self, update_data): + """ + Fuehrt ein Batch-Update im Google Sheet durch. + + Args: + update_data (list): Eine Liste von Dictionaries, jedes mit 'range' (str) + und 'values' (list of lists). + Returns: + bool: True bei Erfolg, False bei endgueltigem Fehler. + """ + if not self.sheet: + self.logger.error("FEHLER: Keine Sheet-Verbindung fuer Batch-Update.") + return False + if not update_data: + return True + + try: + total_cells_to_update = sum(len(row) for item in update_data for row in item.get('values', [])) + self.logger.debug(f" -> Versuche sheet.batch_update mit {len(update_data)} Anfragen ({total_cells_to_update} Zellen)...") + self.sheet.batch_update(update_data, value_input_option='USER_ENTERED') + return True + except Exception: + self.logger.error(f"Endgueltiger Fehler beim Batch-Update nach Retries. Kann {len(update_data)} Operationen nicht durchfuehren.") + return False + +# --- END OF FILE google_sheet_handler.py --- \ No newline at end of file