[30388f42] Infrastructure Hardening: Repaired CE/Connector DB schema, fixed frontend styling build, implemented robust echo shield in worker v2.1.1, and integrated Lead Engine into gateway.

This commit is contained in:
2026-03-07 14:08:42 +00:00
parent efcaa57cf0
commit ae2303b733
404 changed files with 24100 additions and 13301 deletions

View File

@@ -0,0 +1,75 @@
# Archivierte Fotograf.de Tools
Dieses Verzeichnis (`ARCHIVE_vor_migration/Fotograf.de/`) enthält zwei archivierte Tools, die zuvor für die Interaktion mit `app.fotograf.de` und die Erstellung von Google Docs Teilnehmerlisten verwendet wurden.
Beide Tools sind hier isoliert und dokumentiert, um eine spätere Wiederverwendung und Überarbeitung zu erleichtern.
## 1. Fotograf.de Scraper
**Verzeichnis:** `./scraper/`
**Zweck:**
Ein Python-basiertes Skript, das die Website `app.fotograf.de` automatisiert besucht, sich anmeldet und in zwei Modi Daten extrahiert:
1. **E-Mail-Liste erstellen:** Sammelt Kontaktdaten (Käufer, E-Mail, Kindnamen, Login-URLs) und speichert sie in einer CSV-Datei (`supermailer_fertige_liste.csv`).
2. **Statistik auswerten:** Erstellt eine Statistik-CSV-Datei (`job_statistik.csv`) über Album-Käufe.
**Benötigte Dateien:**
* `./scraper/scrape_fotograf.py`: Das Hauptskript mit der gesamten Logik.
* `./scraper/fotograf_credentials.json`: **(Manuell zu erstellen!)** Diese Datei muss Ihre Login-Daten für `app.fotograf.de` im folgenden JSON-Format enthalten:
```json
{
"PROFILNAME": {
"username": "IHR_BENUTZERNAME",
"password": "IHR_PASSWORT"
}
}
```
**Ausführung über Docker:**
Das Tool wird in einer Docker-Umgebung ausgeführt, die Google Chrome und Selenium bereitstellt.
1. **Image bauen (einmalig oder bei Änderungen an Dockerfile/requirements.txt):**
Navigieren Sie zum Root-Verzeichnis des Hauptprojekts (`/app`) und verwenden Sie das dortige `Dockerfile.brancheneinstufung`:
```bash
cd /app
docker build -f Dockerfile.brancheneinstufung -t fotograf-scraper .
```
*(Hinweis: Das `Dockerfile.brancheneinstufung` verwendet die globale `requirements.txt` im Root-Verzeichnis, welche `selenium` enthält.)*
2. **Container starten und Skript ausführen:**
Vom Root-Verzeichnis des Hauptprojekts (`/app`) aus:
```bash
cd /app
docker run -it --rm -v "$(pwd):/app" fotograf-scraper python3 /app/ARCHIVE_vor_migration/Fotograf.de/scraper/scrape_fotograf.py
```
Das Skript fragt Sie interaktiv nach dem gewünschten Modus und der URL des Fotoauftrags.
## 2. Google Docs Teilnehmerlisten-Generator
**Verzeichnis:** `./list_generator/`
**Zweck:**
Ein Python-Skript, das CSV-Dateien einliest und daraus formatierte Teilnehmerlisten als neues Google Docs-Dokument im Google Drive erstellt. Das Tool ist interaktiv und fragt beim Start Details wie den Namen der Veranstaltung, den Einrichtungstyp und den Ausgabemodus ab.
**Benötigte Dateien:**
* `./list_generator/list_generator.py`: Das Hauptskript mit der gesamten Logik.
* `./list_generator/Namensliste.csv`: **(Manuell zu erstellen!)** Eine CSV-Datei mit den Anmeldungen für Kindergärten/Schulen.
* `./list_generator/familien.csv`: **(Manuell zu erstellen!)** Eine CSV-Datei mit den Anmeldungen für Familien-Shootings.
* `./list_generator/service_account.json`: **(Manuell zu erstellen!)** Die JSON-Datei mit den Anmeldeinformationen für den Google Cloud Service Account. Diese Datei wird benötigt, um auf Google Docs und Google Drive zuzugreifen.
**Ausführung:**
Navigieren Sie in das Verzeichnis des Tools und starten Sie es mit Python:
```bash
cd /app/ARCHIVE_vor_migration/Fotograf.de/list_generator/
python3 list_generator.py
```
*(Stellen Sie sicher, dass alle benötigten Python-Bibliotheken wie `google-api-python-client` etc. in Ihrer Umgebung installiert sind. Diese sind vermutlich über die globale `requirements.txt` im Root-Verzeichnis des Hauptprojekts verfügbar.)*
## Wichtiger Hinweis zu Credentials (Sicherheit)
Die Tools verwenden `fotograf_credentials.json` und `service_account.json` zur Authentifizierung. Diese Dateien enthalten sensitive Zugangsdaten und wurden **bewusst aus der Git-Historie entfernt** und nicht im Repository abgelegt.
**Für die Wiederinbetriebnahme müssen diese Dateien manuell im jeweiligen Tool-Verzeichnis (`./scraper/` bzw. `./list_generator/`) erstellt und mit den korrekten Zugangsdaten befüllt werden.**
**Priorität für die Überarbeitung:** Bei einer zukünftigen Überarbeitung dieser Tools ist es **zwingend erforderlich**, die Handhabung der Credentials zu verbessern. Statt fester JSON-Dateien sollten Umgebungsvariablen (`.env`) oder ein sicherer Secret Management Service verwendet werden, um die Sicherheitsstandards zu erhöhen.

View File

@@ -0,0 +1,22 @@
# Google Docs Teilnehmerlisten-Generator (Archiviert)
Dieses Verzeichnis enthält die archivierten Dateien für den "Google Docs Teilnehmerlisten-Generator".
**Zweck:**
Ein Python-Skript, das CSV-Dateien einliest und daraus formatierte Teilnehmerlisten als neues Google Docs-Dokument im Google Drive erstellt. Das Tool ist interaktiv und fragt beim Start Details wie den Namen der Veranstaltung ab.
**Zugehörige Dateien in diesem Ordner:**
* `list_generator.py`: Das Hauptskript mit der gesamten Logik.
**Manuell zu erstellende Dateien:**
Diese Dateien werden vom Skript als Input benötigt und müssen im selben Verzeichnis liegen:
* `Namensliste.csv`: Eine CSV-Datei mit den Anmeldungen für Kindergärten/Schulen.
* `familien.csv`: Eine CSV-Datei mit den Anmeldungen für Familien-Shootings.
* `service_account.json`: Die JSON-Datei mit den Anmeldeinformationen für den Google Cloud Service Account, der die Berechtigung hat, auf Google Docs und Google Drive zuzugreifen.
**Hinweis zur Ausführung:**
Das Skript wird direkt mit Python ausgeführt, z.B.:
```bash
python3 list_generator.py
```
Stellen Sie sicher, dass alle benötigten Bibliotheken (wie `google-api-python-client`, `google-auth-httplib2`, `google-auth-oauthlib`) in Ihrer Python-Umgebung installiert sind. Diese sind vermutlich in der globalen `requirements.txt` im Root-Verzeichnis des Projekts enthalten.

View File

@@ -0,0 +1,301 @@
import csv
from datetime import datetime, timedelta
import collections
import os.path
import json
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
# --- Konfiguration ---
GRUPPEN_CSV_FILENAME = 'Namensliste.csv'
FAMILIEN_CSV_FILENAME = 'familien.csv'
TARGET_FOLDER_ID = "18DNQaH9zbcBzwhckJI-4Uah-WXTXg6bg"
LAST_INPUT_FILE = "listen_generator_last_input.json"
DEFAULT_EINRICHTUNG_ODER_EVENT = "Meine Veranstaltung"
DEFAULT_FOTODATUM_ODER_EVENTDATUM = "TT.MM.JJJJ"
DEFAULT_AUSGABEMODUS = "e"
DEFAULT_EINRICHTUNGSTYP = "k"
GRUPPENNAME_SUFFIX = "gruppe"
SCOPES = [
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/drive.file'
]
SERVICE_ACCOUNT_FILE = 'service_account.json'
# --- Ende Konfiguration ---
def get_last_inputs():
try:
with open(LAST_INPUT_FILE, 'r') as f:
data = json.load(f)
return (data.get("einrichtungstyp", DEFAULT_EINRICHTUNGSTYP),
data.get("einrichtung_event", DEFAULT_EINRICHTUNG_ODER_EVENT),
data.get("datum_info", DEFAULT_FOTODATUM_ODER_EVENTDATUM),
data.get("ausgabemodus", DEFAULT_AUSGABEMODUS))
except (FileNotFoundError, json.JSONDecodeError):
return DEFAULT_EINRICHTUNGSTYP, DEFAULT_EINRICHTUNG_ODER_EVENT, DEFAULT_FOTODATUM_ODER_EVENTDATUM, DEFAULT_AUSGABEMODUS
def save_last_inputs(einrichtungstyp, einrichtung_event, datum_info, ausgabemodus):
try:
with open(LAST_INPUT_FILE, 'w') as f:
json.dump({"einrichtungstyp": einrichtungstyp,
"einrichtung_event": einrichtung_event,
"datum_info": datum_info,
"ausgabemodus": ausgabemodus}, f)
except IOError:
print("Fehler beim Speichern der letzten Eingaben.")
def get_services_with_service_account():
creds = None; docs_service = None; drive_service = None
try: creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
except Exception as e: print(f"Fehler Credentials: {e}"); return None, None
try: docs_service = build('docs', 'v1', credentials=creds); print("Docs API Service erstellt.")
except Exception as e: print(f"Fehler Docs Service: {e}")
try:
if any(s in SCOPES for s in ['https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive']):
drive_service = build('drive', 'v3', credentials=creds); print("Drive API Service erstellt.")
else: print("WARNUNG: Kein Drive Scope für Drive Service.")
except Exception as e: print(f"Fehler Drive Service: {e}")
return docs_service, drive_service
def parse_datetime_string(datetime_str):
try:
dt_obj = datetime.strptime(datetime_str, "%d.%m.%Y %H:%M")
return dt_obj, dt_obj.strftime("%d.%m.%Y"), dt_obj.strftime("%H:%M")
except ValueError:
return None, None, None
def generate_kita_schule_content(docs_service, document_id_to_fill, ausgabemodus_kuerzel, einrichtungstyp_kuerzel):
kinder_nach_gruppen = collections.defaultdict(list)
try:
with open(GRUPPEN_CSV_FILENAME, mode='r', encoding='utf-8-sig', newline='') as csvfile:
reader = csv.DictReader(csvfile, delimiter=';')
for row in reader:
vorname = row.get('Vorname Kind','').strip() or row.get('Vorname','').strip()
nachname = row.get('Nachname Kind','').strip() or row.get('Nachname','').strip()
gruppe_original = row.get('Gruppe','').strip() or row.get('Klasse','').strip()
if not vorname or not nachname or not gruppe_original: continue
einzelfotos_wert = row.get('Einzelfotos', 'Nein').strip()
gruppenfotos_wert = row.get('Gruppenfotos', 'Nein').strip()
einzelfotos_display = "" if einzelfotos_wert.lower() == 'ja' else "Nein"
gruppenfotos_display = "" if gruppenfotos_wert.lower() == 'ja' else "Nein"
kinder_nach_gruppen[gruppe_original].append({
'Nachname': nachname, 'Vorname': vorname,
'Einzelfotos': einzelfotos_display, 'Gruppenfotos': gruppenfotos_display })
except FileNotFoundError: print(f"FEHLER: '{GRUPPEN_CSV_FILENAME}' nicht gefunden."); return False
except Exception as e: print(f"FEHLER CSV-Lesen ('{GRUPPEN_CSV_FILENAME}'): {e}"); return False
if not kinder_nach_gruppen: print(f"Keine Daten aus '{GRUPPEN_CSV_FILENAME}'."); return False
for gk_key in kinder_nach_gruppen:
kinder_nach_gruppen[gk_key].sort(key=lambda x: (x['Nachname'].lower(), x['Vorname'].lower()))
sorted_gruppen_namen = sorted(kinder_nach_gruppen.keys())
stand_zeit = datetime.now().strftime("%d.%m.%Y %H:%M Uhr")
requests = []
gruppen_spalten_header = "Klasse" if einrichtungstyp_kuerzel == 's' else "Gruppe"
summary_lines = ["Übersicht der Anmeldungen:", ""]
gesamt_anmeldungen = 0
for gruppe_name in sorted_gruppen_namen:
anzahl = len(kinder_nach_gruppen[gruppe_name])
if anzahl < 10:
anzahl_formatiert = f"{anzahl} "
else:
anzahl_formatiert = f"{anzahl} "
summary_lines.append(f"{gruppen_spalten_header} {gruppe_name}\t{anzahl_formatiert}Anmeldungen")
gesamt_anmeldungen += anzahl
summary_lines.append("--------------------")
summary_lines.append(f"Gesamt: {gesamt_anmeldungen} Anmeldungen")
summary_lines.append("\n" + "="*70 + "\n")
summary_text = "\n".join(summary_lines)
requests.append({'insertText': {'endOfSegmentLocation': {}, 'text': summary_text}})
col_width_nachname = 22; col_width_vorname = 22; col_width_e = 3; col_width_g = 3
erinnerungstext1 = "Dies ist die Liste der bereits angemeldeten Schüler. Bitte die noch fehlenden"
erinnerungstext2 = "Schüler an die Anmeldung erinnern."
if einrichtungstyp_kuerzel == 'k':
erinnerungstext1 = "Dies ist die Liste der bereits angemeldeten Kinder. Bitte die Eltern der noch fehlenden"
erinnerungstext2 = "Kinder an die Anmeldung erinnern."
for i, gruppe_original in enumerate(sorted_gruppen_namen):
requests.append({'insertPageBreak': {'endOfSegmentLocation': {}}})
kinder_liste = kinder_nach_gruppen[gruppe_original]; anzahl_kinder = len(kinder_liste)
gruppe_display_name = gruppe_original
if einrichtungstyp_kuerzel == 'k':
gruppe_display_name += GRUPPENNAME_SUFFIX
group_block_lines = ["", ""]
kopf_nn = "Nachname".ljust(col_width_nachname); kopf_vn = "Vorname".ljust(col_width_vorname)
if ausgabemodus_kuerzel == "i":
kopf_e = "E".ljust(col_width_e); kopf_g = "G".ljust(col_width_g)
group_block_lines.append(f"{kopf_nn}\t{kopf_vn}\t{kopf_e}\t{kopf_g}\t{gruppen_spalten_header}")
else:
group_block_lines.append(f"{kopf_nn}\t{kopf_vn}\t{gruppen_spalten_header}")
for kind in kinder_liste:
nn_pad = kind['Nachname'].ljust(col_width_nachname); vn_pad = kind['Vorname'].ljust(col_width_vorname)
if ausgabemodus_kuerzel == "i":
e_disp = kind['Einzelfotos'].ljust(col_width_e); g_disp = kind['Gruppenfotos'].ljust(col_width_g)
group_block_lines.append(f"{nn_pad}\t{vn_pad}\t{e_disp}\t{g_disp}\t{gruppe_display_name}")
else:
group_block_lines.append(f"{nn_pad}\t{vn_pad}\t{gruppe_display_name}")
group_block_lines.extend(["", f"{anzahl_kinder} angemeldete Kinder", "",
erinnerungstext1, erinnerungstext2,
"", f"Stand {stand_zeit}", ""])
full_group_block_text = "\n".join(group_block_lines) + "\n"
requests.append({'insertText': {'endOfSegmentLocation': {}, 'text': full_group_block_text}})
if requests:
try:
print(f"Sende Batch Update (inkl. Übersicht) für Doc ID '{document_id_to_fill}'...")
docs_service.documents().batchUpdate(documentId=document_id_to_fill, body={'requests': requests}).execute()
print(f"Dokument erfolgreich befüllt.")
return True
except HttpError as err: print(f"Fehler beim Befüllen: {err}"); return False
return True
def generate_familien_content(docs_service, document_id_to_fill):
termine_nach_tagen = collections.defaultdict(list)
try:
with open(FAMILIEN_CSV_FILENAME, mode='r', encoding='utf-8-sig', newline='') as csvfile:
reader = csv.DictReader(csvfile, delimiter=';')
for row_idx, row in enumerate(reader):
vorname_kind = row.get('Invitee First Name','').strip()
nachname_kind = row.get('Invitee Last Name','').strip()
start_datetime_str = row.get('Start Date & Time', '').strip()
end_datetime_str = row.get('End Date & Time', '').strip()
if not (vorname_kind and nachname_kind and start_datetime_str and end_datetime_str): continue
start_dt_obj, datum_str, startzeit_str = parse_datetime_string(start_datetime_str)
_, _, endzeit_str_val = parse_datetime_string(end_datetime_str)
if not start_dt_obj: continue
kinder_anzahl_str = row.get('Response 1', 'k.A.').strip()
pub_antwort = row.get('Response 2', '').strip()
pub_display = "X" if pub_antwort.lower() == 'ja, gerne' else ""
termine_nach_tagen[datum_str].append({
'Vorname': vorname_kind, 'Nachname': nachname_kind, 'StartzeitObj': start_dt_obj,
'Startzeit': startzeit_str, 'Endzeit': endzeit_str_val, 'Kinder': kinder_anzahl_str,
'Pub': pub_display, 'Erledigt': "" })
except FileNotFoundError: print(f"FEHLER: '{FAMILIEN_CSV_FILENAME}' nicht gefunden."); return False
except Exception as e: print(f"FEHLER CSV-Lesen ('{FAMILIEN_CSV_FILENAME}'): {e}"); return False
if not termine_nach_tagen: print(f"Keine Daten aus '{FAMILIEN_CSV_FILENAME}'."); return False
sorted_tage = sorted(termine_nach_tagen.keys(), key=lambda d: datetime.strptime(d, "%d.%m.%Y"))
requests = []
summary_lines = ["Übersicht der Anmeldungen:", ""]
gesamt_anmeldungen = 0
for tag_datum_str in sorted_tage:
anzahl = len(termine_nach_tagen[tag_datum_str])
if anzahl < 10:
anzahl_formatiert = f"{anzahl} "
else:
anzahl_formatiert = f"{anzahl} "
summary_lines.append(f"Termine am {tag_datum_str}\t{anzahl_formatiert}Anmeldungen")
gesamt_anmeldungen += anzahl
summary_lines.append("--------------------")
summary_lines.append(f"Gesamt: {gesamt_anmeldungen} Anmeldungen")
summary_lines.append("\n" + "="*70 + "\n")
summary_text = "\n".join(summary_lines)
requests.append({'insertText': {'endOfSegmentLocation': {}, 'text': summary_text}})
col_w_vn=20; col_w_nn=20; col_w_zeit=7; col_w_kinder=10; col_w_pub=5; col_w_erl=8
for tag_idx, tag_datum_str in enumerate(sorted_tage):
requests.append({'insertPageBreak': {'endOfSegmentLocation': {}}})
tages_termine = sorted(termine_nach_tagen[tag_datum_str], key=lambda t: t['StartzeitObj'])
tages_block_lines = [f"Termine am {tag_datum_str}", "", ""]
kopf_vn="Vorname".ljust(col_w_vn); kopf_nn="Nachname".ljust(col_w_nn); kopf_zeit="Uhrzeit".ljust(col_w_zeit)
kopf_kinder="# Kinder".ljust(col_w_kinder); kopf_pub="Pub".ljust(col_w_pub); kopf_erl="Erledigt".ljust(col_w_erl)
tages_block_lines.append(f"{kopf_vn}\t{kopf_nn}\t{kopf_zeit}\t{kopf_kinder}\t{kopf_pub}\t{kopf_erl}")
letzte_endzeit_obj = None
for termin in tages_termine:
if letzte_endzeit_obj and termin['StartzeitObj'] > letzte_endzeit_obj:
leere_zeile_parts = [" ".ljust(w) for w in [col_w_vn,col_w_nn,col_w_zeit,col_w_kinder,col_w_pub,col_w_erl]]
tages_block_lines.append("\t".join(leere_zeile_parts))
vn_pad=termin['Vorname'].ljust(col_w_vn); nn_pad=termin['Nachname'].ljust(col_w_nn); zeit_pad=termin['Startzeit'].ljust(col_w_zeit)
kind_pad=termin['Kinder'].ljust(col_w_kinder); pub_pad=termin['Pub'].ljust(col_w_pub); erl_pad=termin['Erledigt'].ljust(col_w_erl)
tages_block_lines.append(f"{vn_pad}\t{nn_pad}\t{zeit_pad}\t{kind_pad}\t{pub_pad}\t{erl_pad}")
_, _, termin_endzeit_str_fuer_obj = parse_datetime_string(f"{tag_datum_str} {termin['Endzeit']}")
if termin_endzeit_str_fuer_obj: letzte_endzeit_obj = datetime.strptime(f"{tag_datum_str} {termin['Endzeit']}", "%d.%m.%Y %H:%M")
else: letzte_endzeit_obj = termin['StartzeitObj'] + timedelta(minutes=6)
full_tages_block_text = "\n".join(tages_block_lines) + "\n\n"
requests.append({'insertText': {'endOfSegmentLocation': {}, 'text': full_tages_block_text}})
if requests:
try:
print(f"Sende Batch Update (Familien Modus, inkl. Übersicht) für Doc ID '{document_id_to_fill}'...")
docs_service.documents().batchUpdate(documentId=document_id_to_fill, body={'requests': requests}).execute()
print("Dokument erfolgreich im Familien Modus befüllt.")
return True
except HttpError as err: print(f"Fehler beim Befüllen (Familien Modus): {err}"); return False
return True
# --- Main execution block ---
if __name__ == "__main__":
last_einrichtungstyp, last_einrichtung_event, last_datum_info, last_ausgabemodus = get_last_inputs()
print("\nBitte geben Sie die Details ein:")
while True:
user_einrichtungstyp_input = input(f"Einrichtungstyp (k=Kindergarten, s=Schule) [{last_einrichtungstyp}]: ").lower() or last_einrichtungstyp
if user_einrichtungstyp_input in ["k", "s", "kindergarten", "schule"]:
user_einrichtungstyp_kuerzel = user_einrichtungstyp_input[0]
break
print("Ungültige Eingabe. Bitte 'k' oder 's' wählen.")
user_einrichtung_event = input(f"Name der Einrichtung/Veranstaltung [{last_einrichtung_event}]: ") or last_einrichtung_event
user_datum_info = input(f"Allgemeines Datum (für Info-Block) [{last_datum_info}]: ") or last_datum_info
while True:
user_ausgabemodus_input = input(f"Ausgabemodus (i=intern, e=extern, f=familie) [{last_ausgabemodus}]: ").lower() or last_ausgabemodus
if user_ausgabemodus_input in ["i", "e", "f", "intern", "extern", "familie"]:
if user_ausgabemodus_input.startswith("i"): user_ausgabemodus_kuerzel = "i"
elif user_ausgabemodus_input.startswith("e"): user_ausgabemodus_kuerzel = "e"
elif user_ausgabemodus_input.startswith("f"): user_ausgabemodus_kuerzel = "f"
else: user_ausgabemodus_kuerzel = user_ausgabemodus_input
break
print("Ungültige Eingabe. Bitte 'i', 'e' oder 'f' wählen.")
save_last_inputs(user_einrichtungstyp_kuerzel, user_einrichtung_event, user_datum_info, user_ausgabemodus_kuerzel)
GOOGLE_DOC_TITLE_DYNAMIC = f"Listen_{user_einrichtung_event.replace(' ', '_').replace(',', '')}_{user_ausgabemodus_kuerzel}_{datetime.now().strftime('%Y-%m-%d_%H-%M')}"
print(f"\nInfo: Erstelle Dokument '{GOOGLE_DOC_TITLE_DYNAMIC}' im Modus '{user_ausgabemodus_kuerzel}' für Typ '{user_einrichtungstyp_kuerzel}'")
docs_api_service, drive_api_service = get_services_with_service_account()
if not docs_api_service: print("Konnte Google Docs API Service nicht initialisieren. Skript wird beendet.")
else:
document_id = None
if drive_api_service and TARGET_FOLDER_ID:
file_metadata = {'name': GOOGLE_DOC_TITLE_DYNAMIC, 'mimeType': 'application/vnd.google-apps.document', 'parents': [TARGET_FOLDER_ID]}
try:
created_file = drive_api_service.files().create(body=file_metadata, fields='id').execute()
document_id = created_file.get('id'); print(f"Doc in Ordner '{TARGET_FOLDER_ID}' erstellt, ID: {document_id}")
except Exception as e: print(f"Fehler Drive Erstellung: {e}\n Fallback...")
if not document_id:
try:
doc = docs_api_service.documents().create(body={'title': GOOGLE_DOC_TITLE_DYNAMIC}).execute()
document_id = doc.get('documentId'); print(f"Doc in Root SA erstellt, ID: {document_id}")
if TARGET_FOLDER_ID: print(f" Bitte manuell in Ordner '{TARGET_FOLDER_ID}' verschieben.")
except Exception as e: print(f"Fehler Docs Erstellung: {e}")
if document_id:
initial_info_lines = [ "Info zum Kopieren für Ihre manuelle Kopf-/Fußzeile:", user_einrichtung_event, user_datum_info, "\n" + "="*70 + "\n" ]
initial_text = "\n".join(initial_info_lines)
initial_requests = [{'insertText': {'location': {'index': 1}, 'text': initial_text}}]
try: docs_api_service.documents().batchUpdate(documentId=document_id, body={'requests': initial_requests}).execute(); print("Einmalige Info eingefügt.")
except HttpError as err: print(f"Fehler bei einmaliger Info: {err}")
success_filling = False
if user_ausgabemodus_kuerzel in ["i", "e"]:
print(f"Starte Kita/Schule-Modus ('{user_ausgabemodus_kuerzel}')...")
success_filling = generate_kita_schule_content(docs_api_service, document_id, user_ausgabemodus_kuerzel, user_einrichtungstyp_kuerzel)
elif user_ausgabemodus_kuerzel == "f":
print("Starte Familien-Modus...")
success_filling = generate_familien_content(docs_api_service, document_id)
else: print(f"FEHLER: Unbekannter Ausgabemodus '{user_ausgabemodus_kuerzel}'")
if success_filling:
print(f"\n--- SKRIPT BEENDET ---"); print(f"Dokument-ID: {document_id}"); print(f"Link: https://docs.google.com/document/d/{document_id}/edit")
print("HINWEIS: Text markieren und 'Einfügen > Tabelle > Tabelle aus Text erstellen...' verwenden.")
print(" Kopfzeilen manuell fett formatieren.")
else: print("\n--- FEHLER BEIM BEFÜLLEN MIT DATEN ---")
else: print("\n--- FEHLGESCHLAGEN: Kein Dokument erstellt ---")

View File

@@ -0,0 +1,32 @@
# Fotograf.de Scraper (Archiviert)
Dieses Verzeichnis enthält die archivierten Dateien für den "Fotograf.de Scraper".
**Zweck:**
Ein Python-basiertes Tool, das die Website `app.fotograf.de` automatisiert besucht, sich anmeldet und in zwei Modi Daten extrahiert:
1. **E-Mail-Liste erstellen:** Sammelt Kontaktdaten und speichert sie in einer CSV-Datei (`supermailer_fertige_liste.csv`).
2. **Statistik auswerten:** Erstellt eine Statistik-CSV-Datei (`job_statistik.csv`).
**Zugehörige Dateien in diesem Ordner:**
* `scrape_fotograf.py`: Das Hauptskript mit der gesamten Logik.
**Manuell zu erstellende Dateien:**
* `fotograf_credentials.json`: Diese Datei wird vom Skript benötigt und muss die Login-Daten für `app.fotograf.de` im folgenden JSON-Format enthalten:
```json
{
"PROFILNAME": {
"username": "IHR_BENUTZERNAME",
"password": "IHR_PASSWORT"
}
}
```
**Externe Abhängigkeiten (befinden sich im Hauptverzeichnis des Projekts):**
* **Dockerfile:** `Dockerfile.brancheneinstufung` wurde wahrscheinlich verwendet, um ein Docker-Image für dieses Tool zu erstellen. Es installiert Google Chrome und die notwendigen Python-Pakete.
* **Python-Abhängigkeiten:** Die globale `requirements.txt` im Root-Verzeichnis enthält `selenium` und andere benötigte Bibliotheken.
**Beispielhafter `docker run`-Befehl:**
1. Bauen Sie das Image (nur einmalig): `docker build -f Dockerfile.brancheneinstufung -t fotograf-scraper .`
2. Führen Sie den Container aus: `docker run -it --rm -v "$(pwd):/app" fotograf-scraper python3 /app/ARCHIVE_vor_migration/Fotograf.de/scraper/scrape_fotograf.py`
(Pfade müssen ggf. angepasst werden, je nachdem, von wo der Befehl ausgeführt wird.)

View File

@@ -0,0 +1,427 @@
import json
import os
import time
import csv
import math
import re
from datetime import datetime
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, NoSuchElementException, StaleElementReferenceException, InvalidArgumentException
# --- Konfiguration & Konstanten ---
CREDENTIALS_FILE = 'fotograf_credentials.json'
OUTPUT_DIR = 'output'
LOGIN_URL = 'https://app.fotograf.de/login/login'
# --- Selektoren ---
SELECTORS = {
"cookie_accept_button": "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll",
"login_user": "#login-email",
"login_pass": "#login-password",
"login_button": "#login-submit",
"job_name": "h1",
"album_overview_rows": "//table/tbody/tr",
"album_overview_link": ".//td[2]//a",
"access_code_count": "//span[text()='Zugangscodes']/following-sibling::strong",
"person_rows": "//div[contains(@class, 'border-legacy-silver-550') and .//span[text()='Logins']]",
"person_vorname": ".//span[text()='Vorname']/following-sibling::strong",
"person_logins": ".//span[text()='Logins']/following-sibling::strong",
"person_access_code_link": ".//a[contains(@data-qa-id, 'guest-access-banner-access-code')]",
# Selektoren für die Statistik-Zählung
"person_all_photos": ".//div[@data-key]",
"person_purchased_photos": ".//div[@data-key and .//img[@alt='Bestellungen mit diesem Foto']]",
"person_access_card_photo": ".//div[@data-key and contains(@class, 'opacity-50')]", # NEU: Identifiziert die Zugangskarte
"potential_buyer_link": "//a[contains(@href, '/config_customers/view_customer')]",
"quick_login_url": "//a[@id='quick-login-url']",
"buyer_email": "//span[contains(., '@')]"
}
def take_error_screenshot(driver, error_name):
os.makedirs(OUTPUT_DIR, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"error_{error_name}_{timestamp}.png"
filepath = os.path.join(OUTPUT_DIR, filename)
try:
driver.save_screenshot(filepath)
print(f"!!! Fehler aufgetreten. Screenshot gespeichert unter: {filepath}")
except Exception as e:
print(f"!!! Konnte keinen Screenshot speichern: {e}")
def setup_driver():
print("Initialisiere Chrome WebDriver...")
options = Options()
options.add_argument('--headless')
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--window-size=1920,1200')
options.binary_location = '/usr/bin/google-chrome'
try:
driver = webdriver.Chrome(options=options)
return driver
except Exception as e:
print(f"Fehler bei der Initialisierung des WebDrivers: {e}")
return None
def load_all_credentials():
try:
with open(CREDENTIALS_FILE, 'r') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def login(driver, username, password):
print("Starte Login-Vorgang...")
try:
driver.get(LOGIN_URL)
wait = WebDriverWait(driver, 10)
try:
print("Suche nach Cookie-Banner...")
wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, SELECTORS["cookie_accept_button"]))).click()
print("Cookie-Banner akzeptiert.")
time.sleep(1)
except TimeoutException:
print("Kein Cookie-Banner gefunden, fahre fort.")
print("Fülle Anmeldeformular aus...")
wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, SELECTORS["login_user"]))).send_keys(username)
driver.find_element(By.CSS_SELECTOR, SELECTORS["login_pass"]).send_keys(password)
print("Klicke auf Login...")
driver.find_element(By.CSS_SELECTOR, SELECTORS["login_button"]).click()
print("Warte auf die nächste Seite...")
wait.until(EC.url_contains('/config_dashboard/index'))
print("Login erfolgreich!")
return True
except Exception as e:
print(f"Login fehlgeschlagen. Grund: {e}")
take_error_screenshot(driver, "login_error")
return False
def process_reminder_mode(driver, job_url):
wait = WebDriverWait(driver, 15)
try:
job_id_match = re.search(r'/(\d+)', job_url)
if not job_id_match: raise ValueError("Konnte keine numerische Job-ID finden.")
job_id = job_id_match.group(1)
settings_url = f"https://app.fotograf.de/config_jobs_settings/index/{job_id}"
except Exception as e:
print(f"!!! FEHLER: Konnte keine Job-ID aus der URL '{job_url}' extrahieren. Grund: {e}")
return []
print(f"\nVerarbeite Job-ID: {job_id}")
driver.get(settings_url)
try:
job_name = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, SELECTORS["job_name"]))).text
print(f"Auftragsname: '{job_name}'")
except TimeoutException:
print("Konnte den Auftragsnamen nicht finden.")
return []
albums_overview_url = f"https://app.fotograf.de/config_jobs_photos/index/{job_id}"
print(f"Navigiere zur Alben-Übersicht: {albums_overview_url}")
driver.get(albums_overview_url)
albums_to_visit = []
try:
album_rows = wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["album_overview_rows"])))
print(f"{len(album_rows)} Alben in der Übersicht gefunden.")
for row in album_rows:
try:
album_link = row.find_element(By.XPATH, SELECTORS["album_overview_link"])
albums_to_visit.append({"name": album_link.text, "url": album_link.get_attribute('href')})
except NoSuchElementException:
continue
print(f"{len(albums_to_visit)} gültige Album-Links gesammelt.")
except TimeoutException:
print("Konnte die Album-Liste nicht finden.")
return []
final_results = []
for album in albums_to_visit:
print(f"\n--- Betrete Album: {album['name']} ---")
driver.get(album['url'])
try:
total_codes_text = wait.until(EC.visibility_of_element_located((By.XPATH, SELECTORS["access_code_count"]))).text
num_pages = math.ceil(int(total_codes_text) / 20)
print(f"Album hat {total_codes_text} Zugangscodes auf {num_pages} Seite(n).")
for page_num in range(1, num_pages + 1):
current_page_url = album['url']
if page_num > 1: current_page_url += f"?page_guest_accesses={page_num}"
print(f" Verarbeite Seite {page_num}...")
driver.get(current_page_url)
num_persons = len(wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["person_rows"]))))
print(f" {num_persons} Personen auf dieser Seite gefunden.")
for i in range(num_persons):
person_rows = wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["person_rows"])))
person_row = person_rows[i]
login_count_text = person_row.find_element(By.XPATH, SELECTORS["person_logins"]).text
if int(login_count_text) <= 1:
vorname = person_row.find_element(By.XPATH, SELECTORS["person_vorname"]).text
try:
photo_container = person_row.find_element(By.XPATH, "./following-sibling::div[1]")
purchase_icons = photo_container.find_elements(By.XPATH, SELECTORS["person_purchased_photos"])
if len(purchase_icons) > 0:
print(f" --> INFO: '{vorname}' hat bereits gekauft. Überspringe.")
continue
except NoSuchElementException:
pass
print(f" --> ERFOLG: '{vorname}' mit {login_count_text} Login(s) gefunden (und kein Kauf).")
access_code_page_url = person_row.find_element(By.XPATH, SELECTORS["person_access_code_link"]).get_attribute('href')
driver.get(access_code_page_url)
print(f" Navigiere zur Kommunikations-Seite für '{vorname}'...")
for attempt in range(3):
try:
wait.until(EC.visibility_of_element_located((By.XPATH, SELECTORS["quick_login_url"])))
schnell_login_url = driver.find_element(By.XPATH, SELECTORS["quick_login_url"]).get_attribute('href')
potential_buyer_element = driver.find_element(By.XPATH, SELECTORS["potential_buyer_link"])
kaeufer_name = potential_buyer_element.text
print(f" Käufer: '{kaeufer_name}', Schnell-Login: GEFUNDEN")
potential_buyer_element.click()
print(f" Navigiere zur Käufer-Detailseite...")
email = wait.until(EC.visibility_of_element_located((By.XPATH, SELECTORS["buyer_email"]))).text
print(f" FINALE ERFOLG: E-Mail gefunden: {email}")
final_results.append({
"Name des Kindes": vorname,
"Name Käufer": kaeufer_name,
"E-Mail-Adresse Käufer": email,
"Schnell Login URL": schnell_login_url
})
break
except StaleElementReferenceException:
print(f" Timing-Fehler, Versuch {attempt + 1}/3...")
time.sleep(1)
if attempt == 2: raise
except TimeoutException:
print(f" Timeout beim Warten auf Details für '{vorname}'.")
take_error_screenshot(driver, f"timeout_error_{vorname}")
break
print(f" Kehre zurück zur Album-Seite {page_num}...")
driver.get(current_page_url)
wait.until(EC.presence_of_element_located((By.XPATH, SELECTORS["person_rows"])))
except TimeoutException:
print(f" Keine Personen-Daten im Album '{album['name']}' gefunden. Überspringe.")
continue
return final_results
def aggregate_results_by_email(results):
print("\nBeginne mit der Aggregation der Ergebnisse pro E-Mail-Adresse...")
aggregated_data = {}
for result in results:
email = result['E-Mail-Adresse Käufer']
child_name = "Familienbilder" if result['Name des Kindes'] == "Familie" else result['Name des Kindes']
html_link = f'<a href="{result["Schnell Login URL"]}">Fotos von {child_name}</a>'
if email not in aggregated_data:
aggregated_data[email] = {
'Name Käufer': result['Name Käufer'].split(' ')[0],
'E-Mail-Adresse Käufer': email,
'Kindernamen_list': [child_name],
'LinksHTML_list': [html_link]
}
else:
aggregated_data[email]['Kindernamen_list'].append(child_name)
aggregated_data[email]['LinksHTML_list'].append(html_link)
final_list = []
for email, data in aggregated_data.items():
names_list = data['Kindernamen_list']
if len(names_list) > 2:
kindernamen_str = ', '.join(names_list[:-1]) + ' und ' + names_list[-1]
else:
kindernamen_str = ' und '.join(names_list)
final_list.append({
'Name Käufer': data['Name Käufer'],
'E-Mail-Adresse Käufer': email,
'Kindernamen': kindernamen_str,
'LinksHTML': '<br><br>'.join(data['LinksHTML_list'])
})
print(f"Aggregation abgeschlossen. {len(results)} Roh-Einträge zu {len(final_list)} einzigartigen E-Mails zusammengefasst.")
return final_list
def save_aggregated_results_to_csv(results):
if not results:
print("\nKeine Daten zum Speichern vorhanden.")
return
output_file = os.path.join(OUTPUT_DIR, 'supermailer_fertige_liste.csv')
os.makedirs(OUTPUT_DIR, exist_ok=True)
fieldnames = ["Name Käufer", "E-Mail-Adresse Käufer", "Kindernamen", "LinksHTML"]
print(f"\nSpeichere {len(results)} aggregierte Ergebnisse in '{output_file}'...")
with open(output_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(results)
print("Speichern erfolgreich!")
# --- Modus 2: Statistik-Auswertung ---
def process_statistics_mode(driver, job_url):
wait = WebDriverWait(driver, 15)
try:
job_id = re.search(r'/(\d+)', job_url).group(1)
except Exception:
print(f"!!! FEHLER: Konnte keine Job-ID aus der URL '{job_url}' extrahieren.")
return []
albums_overview_url = f"https://app.fotograf.de/config_jobs_photos/index/{job_id}"
print(f"Navigiere zur Alben-Übersicht: {albums_overview_url}")
driver.get(albums_overview_url)
albums_to_visit = []
try:
album_rows = wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["album_overview_rows"])))
for row in album_rows:
try:
album_link = row.find_element(By.XPATH, SELECTORS["album_overview_link"])
albums_to_visit.append({"name": album_link.text, "url": album_link.get_attribute('href')})
except NoSuchElementException: continue
except TimeoutException:
print("Konnte die Album-Liste nicht finden.")
return []
statistics = []
print("\n--- STATISTIK-AUSWERTUNG ---")
for album in albums_to_visit:
print(f"\nAlbum: {album['name']}")
driver.get(album['url'])
try:
total_codes_text = wait.until(EC.visibility_of_element_located((By.XPATH, SELECTORS["access_code_count"]))).text
num_pages = math.ceil(int(total_codes_text) / 20)
total_children_in_album = 0
children_with_purchase = 0
children_with_all_purchased = 0
for page_num in range(1, num_pages + 1):
if page_num > 1: driver.get(album['url'] + f"?page_guest_accesses={page_num}")
person_rows = wait.until(EC.presence_of_all_elements_located((By.XPATH, SELECTORS["person_rows"])))
for person_row in person_rows:
total_children_in_album += 1
try:
photo_container = person_row.find_element(By.XPATH, "./following-sibling::div[1]")
# GEÄNDERTE ZÄHLLOGIK
num_total_photos = len(photo_container.find_elements(By.XPATH, SELECTORS["person_all_photos"]))
num_purchased_photos = len(photo_container.find_elements(By.XPATH, SELECTORS["person_purchased_photos"]))
num_access_cards = len(photo_container.find_elements(By.XPATH, SELECTORS["person_access_card_photo"]))
buyable_photos = num_total_photos - num_access_cards
if num_purchased_photos > 0:
children_with_purchase += 1
if buyable_photos > 0 and buyable_photos == num_purchased_photos:
children_with_all_purchased += 1
except NoSuchElementException:
continue
print(f" - Kinder insgesamt: {total_children_in_album}")
print(f" - Kinder mit (mind. 1) Kauf: {children_with_purchase}")
print(f" - Kinder (Alle Bilder gekauft): {children_with_all_purchased}")
statistics.append({
"Album": album['name'],
"Kinder insgesamt": total_children_in_album,
"Kinder mit Käufen": children_with_purchase,
"Kinder (Alle Bilder gekauft)": children_with_all_purchased
})
except Exception as e:
print(f" Fehler bei der Auswertung dieses Albums: {e}")
continue
return statistics
def save_statistics_to_csv(results):
if not results:
print("\nKeine Statistikdaten zum Speichern vorhanden.")
return
output_file = os.path.join(OUTPUT_DIR, 'job_statistik.csv')
os.makedirs(OUTPUT_DIR, exist_ok=True)
fieldnames = ["Album", "Kinder insgesamt", "Kinder mit Käufen", "Kinder (Alle Bilder gekauft)"]
print(f"\nSpeichere Statistik für {len(results)} Alben in '{output_file}'...")
with open(output_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(results)
print("Speichern erfolgreich!")
def get_profile_choice():
all_credentials = load_all_credentials()
if not all_credentials: return None
profiles = list(all_credentials.keys())
print("\nBitte wähle das zu verwendende Profil:")
for i, p in enumerate(profiles): print(f" {i + 1}) {p}")
while True:
try:
c = int(input(f"Gib eine Zahl zwischen 1 und {len(profiles)} ein: "))
if 1 <= c <= len(profiles):
p_name = profiles[c - 1]
print(f"Profil '{p_name}' ausgewählt.")
return all_credentials[p_name]
else: print("Ungültige Auswahl.")
except ValueError: print("Ungültige Eingabe.")
def main():
print("--- Fotograf.de Scraper (v3.2 - The Master Analyst) ---")
while True:
mode = input("Bitte Modus wählen:\n 1) E-Mail-Liste erstellen\n 2) Statistik auswerten\nWahl: ")
if mode in ['1', '2']: break
else: print("Ungültige Eingabe.")
credentials = get_profile_choice()
if not credentials: return
job_url_raw = input("Bitte eine beliebige URL des zu bearbeitenden Fotoauftrags ein: ")
match = re.search(r'(https?://[^\s]+)', job_url_raw)
if not match:
print("Keine gültige URL in der Eingabe gefunden.")
return
job_url = match.group(1).strip()
if "fotograf.de/config_jobs_" not in job_url or not re.search(r'/\d+', job_url):
print("Dies scheint keine gültige URL für einen Fotoauftrag zu sein.")
return
driver = setup_driver()
if not driver: return
try:
if login(driver, credentials['username'], credentials['password']):
if mode == '1':
raw_results = process_reminder_mode(driver, job_url)
aggregated_results = aggregate_results_by_email(raw_results)
save_aggregated_results_to_csv(aggregated_results)
elif mode == '2':
stats_results = process_statistics_mode(driver, job_url)
save_statistics_to_csv(stats_results)
else:
print("Skript wird beendet, da der Login fehlgeschlagen ist.")
finally:
print("\nSkript beendet. Schließe WebDriver.")
if driver: driver.quit()
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,308 @@
# --- 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._all_data = [] # KORREKTUR: Variable umbenannt
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 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:
# NEU: Prüfen, ob eine Verbindung besteht, und ggf. herstellen
if not self.client:
if not self._connect():
self.logger.error("Verbindung zu Google Sheets konnte nicht hergestellt werden. 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(self.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)
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 ---