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
This commit is contained in:
root
2025-10-23 12:43:22 +00:00
parent 0d54243e9d
commit 9312586f18
5 changed files with 400 additions and 5 deletions

255
.gitignore vendored Normal file
View File

@@ -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

92
GOOGLE_SETUP_README.md Normal file
View File

@@ -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

19
bitbylaw/check_db.py Normal file
View File

@@ -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())

View File

@@ -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

View File

@@ -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)