From 9312586f18afdeac47f2e6ce28073ddaf8075373 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Oct 2025 12:43:22 +0000 Subject: [PATCH] Fix Advoware timestamp parsing bug causing sync loops - Change .replace(tzinfo=BERLIN_TZ) to BERLIN_TZ.localize() for correct pytz TZ handling - Update Phase 4 to use proper TZ localization for adv_ts - Add documentation section on correct Advoware timestamp handling - Add .gitignore, GOOGLE_SETUP_README.md, and check_db.py utility --- .gitignore | 255 ++++++++++++++++++ GOOGLE_SETUP_README.md | 92 +++++++ bitbylaw/check_db.py | 19 ++ bitbylaw/steps/advoware_cal_sync/README.md | 28 ++ .../calendar_sync_event_step.py | 11 +- 5 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 GOOGLE_SETUP_README.md create mode 100644 bitbylaw/check_db.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1c16f34d --- /dev/null +++ b/.gitignore @@ -0,0 +1,255 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the executable, and should not be committed. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# Google Service Account credentials +service-account.json +token.pickle +credentials.json + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +public + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Logs +logs +*.log + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Invoke +.inv + +# NPM cache +.npm-cache \ No newline at end of file diff --git a/GOOGLE_SETUP_README.md b/GOOGLE_SETUP_README.md new file mode 100644 index 00000000..90738cbe --- /dev/null +++ b/GOOGLE_SETUP_README.md @@ -0,0 +1,92 @@ +# Google Service Account Setup für Advoware Calendar Sync + +## Übersicht +Dieser Calendar Sync verwendet **ausschließlich Google Service Accounts** für die Authentifizierung. Kein OAuth, kein Browser-Interaktion - perfekt für Server-Umgebungen! + +## Voraussetzungen +- Google Cloud Console Zugang +- Berechtigung zum Erstellen von Service Accounts +- (Optional) Google Workspace Admin Zugang für Domain-wide Delegation + +## Schritt 1: Google Cloud Console aufrufen +1. Gehen Sie zu: https://console.cloud.google.com/ +2. Melden Sie sich mit Ihrem Google-Konto an +3. Wählen Sie ein bestehendes Projekt aus oder erstellen Sie ein neues + +## Schritt 2: Google Calendar API aktivieren +1. Klicken Sie auf "APIs & Dienste" → "Bibliothek" +2. Suchen Sie nach "Google Calendar API" +3. Klicken Sie auf "Google Calendar API" → "Aktivieren" + +## Schritt 3: Service Account erstellen +1. Gehen Sie zu "IAM & Verwaltung" → "Service-Konten" +2. Klicken Sie auf "+ Service-Konto erstellen" +3. Grundlegende Informationen: + - **Service-Kontoname**: `advoware-calendar-sync` + - **Beschreibung**: `Service Account für Advoware-Google Calendar Synchronisation` + - **E-Mail**: wird automatisch generiert +4. Klicken Sie auf "Erstellen und fortfahren" + +## Schritt 4: Berechtigungen zuweisen +1. **Rolle zuweisen**: Wählen Sie eine der folgenden Optionen: + - Für volle Zugriffe: `Editor` + - Für eingeschränkte Zugriffe: `Calendar API Admin` (falls verfügbar) +2. Klicken Sie auf "Fertig" + +## Schritt 5: JSON-Schlüssel erstellen und installieren +1. Klicken Sie auf das neu erstellte Service-Konto +2. Gehen Sie zum Tab "Schlüssel" +3. Klicken Sie auf "Schlüssel hinzufügen" → "Neuen Schlüssel erstellen" +4. Wählen Sie "JSON" als Schlüsseltyp +5. Klicken Sie auf "Erstellen" +6. Die JSON-Datei wird automatisch heruntergeladen +7. **Benennen Sie die Datei um zu: `service-account.json`** +8. **Kopieren Sie die Datei nach: `/opt/motia-app/service-account.json`** +9. **Stellen Sie sichere Berechtigungen ein:** + ```bash + chmod 600 /opt/motia-app/service-account.json + ``` + +## Schritt 6: Domain-wide Delegation (nur für Google Workspace) +Falls Sie Google Workspace verwenden und auf Kalender anderer Benutzer zugreifen möchten: + +1. Gehen Sie zurück zum Service-Konto +2. Aktivieren Sie "Google Workspace Domain-wide Delegation" +3. Notieren Sie sich die "Unique ID" des Service-Kontos +4. Gehen Sie zu Google Admin Console: https://admin.google.com/ +5. "Sicherheit" → "API-Berechtigungen" +6. "Domain-wide Delegation" → "API-Clienten verwalten" +7. Fügen Sie die Unique ID hinzu +8. Berechtigungen: `https://www.googleapis.com/auth/calendar` + +## Schritt 7: Testen +Nach dem Setup können Sie den Calendar Sync testen: + +```bash +# Vollständige Termindetails synchronisieren +curl -X POST http://localhost:3000/api/flows/advoware_cal_sync \ + -H "Content-Type: application/json" \ + -d '{"full_content": true}' + +# Nur "blocked" Termine synchronisieren (weniger Details) +curl -X POST http://localhost:3000/api/flows/advoware_cal_sync \ + -H "Content-Type: application/json" \ + -d '{"full_content": false}' +``` + +## Wichtige Hinweise +- ✅ **Kein Browser nötig** - läuft komplett server-seitig +- ✅ **Automatisch** - einmal setup, läuft für immer +- ✅ **Sicher** - Service Accounts haben granulare Berechtigungen +- ✅ **Skalierbar** - perfekt für Produktionsumgebungen + +## Fehlerbehebung +- **"service-account.json nicht gefunden"**: Überprüfen Sie den Pfad `/opt/motia-app/service-account.json` +- **"Access denied"**: Überprüfen Sie die Berechtigungen des Service Accounts +- **"API not enabled"**: Stellen Sie sicher, dass Calendar API aktiviert ist +- **"Invalid credentials"**: Überprüfen Sie die service-account.json Datei + +## Sicherheit +- Halten Sie die `service-account.json` Datei sicher und versionieren Sie sie nicht +- Verwenden Sie IAM-Rollen in GCP-Umgebungen statt JSON-Keys +- Rotiere Service Account Keys regelmäßig diff --git a/bitbylaw/check_db.py b/bitbylaw/check_db.py new file mode 100644 index 00000000..911347ab --- /dev/null +++ b/bitbylaw/check_db.py @@ -0,0 +1,19 @@ +import asyncio +import asyncpg +from config import Config + +async def check_db(): + conn = await asyncpg.connect( + host=Config.POSTGRES_HOST or 'localhost', + user=Config.POSTGRES_USER, + password=Config.POSTGRES_PASSWORD, + database=Config.POSTGRES_DB_NAME, + timeout=10 + ) + try: + row = await conn.fetchrow('SELECT * FROM calendar_sync WHERE sync_id = $1', '1329fa1f-9de5-49dc-95c6-a13525f315c5') + print('DB Row:', dict(row) if row else 'No row found') + finally: + await conn.close() + +asyncio.run(check_db()) \ No newline at end of file diff --git a/bitbylaw/steps/advoware_cal_sync/README.md b/bitbylaw/steps/advoware_cal_sync/README.md index 63bafdb5..25bc4239 100644 --- a/bitbylaw/steps/advoware_cal_sync/README.md +++ b/bitbylaw/steps/advoware_cal_sync/README.md @@ -256,5 +256,33 @@ Cron-Step für regelmäßige Ausführung. - Timestamps: Fehlende in Google können zu Fallback führen. - Performance: Bei vielen Terminen könnte Paginierung helfen. +## Korrekter Umgang mit Advoware-Timestamps + +### Problemstellung +Advoware-Timestamps (z.B. `'zuletztGeaendertAm'`) werden in Berlin-Zeit geliefert, aber das Parsing mit `datetime.datetime.fromisoformat(...).replace(tzinfo=BERLIN_TZ)` führte zu falschen Offsets (z.B. 53 Minuten Unterschied), da `replace(tzinfo=...)` auf naive datetime nicht korrekt mit pytz-TZ-Objekten funktioniert. Dies verursachte Endlosschleifen in Phase 4, da `adv_ts` falsch hochgesetzt wurde. + +### Lösung +Verwende `BERLIN_TZ.localize(naive_datetime)` statt `.replace(tzinfo=BERLIN_TZ)`: +- `localize()` setzt die TZ korrekt auf pytz-TZ-Objekte. +- Beispiel: + ```python + naive = datetime.datetime.fromisoformat('2025-10-23T14:18:36.245') + adv_ts = BERLIN_TZ.localize(naive) # Ergebnis: 2025-10-23 14:18:36.245+02:00 + ``` +- Dies stellt sicher, dass Timestamps korrekt in UTC konvertiert werden (z.B. 12:18 UTC) und Vergleiche in Phase 4 funktionieren. + +### Implementierung +- In `calendar_sync_event_step.py`, Phase 4: + ```python + adv_ts = BERLIN_TZ.localize(datetime.datetime.fromisoformat(adv_data['zuletztGeaendertAm'])) + ``` +- Für Google-Timestamps: `.astimezone(BERLIN_TZ)` bleibt korrekt. +- Alle Timestamps werden zu UTC normalisiert für DB-Speicherung und Vergleiche. + +### Vermeidung von Fehlern +- Niemals `.replace(tzinfo=pytz_tz)` verwenden – immer `tz.localize(naive)`. +- Teste Parsing: `BERLIN_TZ.localize(datetime.datetime.fromisoformat(ts)).astimezone(pytz.utc)` sollte korrekte UTC ergeben. +- Bei anderen TZ: Gleiche Regel anwenden. + ## Erweiterungen diff --git a/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py b/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py index d8ece4a8..19e709b3 100644 --- a/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py +++ b/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py @@ -500,7 +500,7 @@ async def handler(event, context): new_frnr = await safe_create_advoware_appointment(advoware, standardize_appointment_data(google_map[event_id], 'google'), employee_kuerzel, row['advoware_write_allowed']) if new_frnr and str(new_frnr) != 'None': async with conn.transaction(): - await conn.execute("UPDATE calendar_sync SET advoware_frnr = $1, sync_status = 'synced' WHERE sync_id = $2;", int(new_frnr), row['sync_id']) + await conn.execute("UPDATE calendar_sync SET advoware_frnr = $1, sync_status = 'synced', last_sync = $3 WHERE sync_id = $2;", int(new_frnr), row['sync_id'], datetime.datetime.now(BERLIN_TZ)) logger.info(f"Phase 3: Recreated Advoware appointment {new_frnr} for sync_id {row['sync_id']}") else: logger.warning(f"Phase 3: Failed to recreate Advoware for sync_id {row['sync_id']}, frNr is None") @@ -557,7 +557,7 @@ async def handler(event, context): try: new_event_id = await create_google_event(service, calendar_id, standardize_appointment_data(adv_map[frnr], 'advoware')) async with conn.transaction(): - await conn.execute("UPDATE calendar_sync SET google_event_id = $1, sync_status = 'synced' WHERE sync_id = $2;", new_event_id, row['sync_id']) + await conn.execute("UPDATE calendar_sync SET google_event_id = $1, sync_status = 'synced', last_sync = $3 WHERE sync_id = $2;", new_event_id, row['sync_id'], datetime.datetime.now(BERLIN_TZ)) logger.info(f"Phase 3: Recreated Google event {new_event_id} for sync_id {row['sync_id']}") except Exception as e: logger.warning(f"Phase 3: Failed to recreate Google for sync_id {row['sync_id']}: {e}") @@ -591,7 +591,7 @@ async def handler(event, context): if strategy == 'source_system_wins': if row['source_system'] == 'advoware': # Check for changes in source (Advoware) or unauthorized changes in target (Google) - adv_ts = datetime.datetime.fromisoformat(adv_data['zuletztGeaendertAm']).astimezone(BERLIN_TZ) + adv_ts = BERLIN_TZ.localize(datetime.datetime.fromisoformat(adv_data['zuletztGeaendertAm'])) google_ts_str = google_data.get('updated', '') google_ts = datetime.datetime.fromisoformat(google_ts_str.rstrip('Z')).astimezone(BERLIN_TZ) if google_ts_str else None if adv_ts > row['last_sync']: @@ -609,7 +609,8 @@ async def handler(event, context): # Check for changes in source (Google) or unauthorized changes in target (Advoware) google_ts_str = google_data.get('updated', '') google_ts = datetime.datetime.fromisoformat(google_ts_str.rstrip('Z')).astimezone(BERLIN_TZ) if google_ts_str else None - adv_ts = datetime.datetime.fromisoformat(adv_data['zuletztGeaendertAm']).astimezone(BERLIN_TZ) + adv_ts = BERLIN_TZ.localize(datetime.datetime.fromisoformat(adv_data['zuletztGeaendertAm'])) + logger.debug(f"Phase 4: Checking sync_id {row['sync_id']}: adv_ts={adv_ts}, google_ts={google_ts}, last_sync={row['last_sync']}") if google_ts and google_ts > row['last_sync']: await safe_update_advoware_appointment(advoware, frnr, google_std, row['advoware_write_allowed'], row['employee_kuerzel']) async with conn.transaction(): @@ -619,7 +620,7 @@ async def handler(event, context): logger.warning(f"Phase 4: Unauthorized change in Advoware frNr {frnr}, resetting to Google event {event_id}") await safe_update_advoware_appointment(advoware, frnr, google_std, row['advoware_write_allowed'], row['employee_kuerzel']) async with conn.transaction(): - await conn.execute("UPDATE calendar_sync SET sync_status = 'synced', last_sync = $2 WHERE sync_id = $1;", row['sync_id'], google_ts) + await conn.execute("UPDATE calendar_sync SET sync_status = 'synced', last_sync = $2 WHERE sync_id = $1;", row['sync_id'], datetime.datetime.now(BERLIN_TZ)) logger.info(f"Phase 4: Reset Advoware frNr {frnr} to Google event {event_id}") elif strategy == 'last_change_wins': adv_ts = await get_advoware_timestamp(advoware, frnr)