diff --git a/bitbylaw/config.py b/bitbylaw/config.py index 8e8b2ed3..b7bc2dda 100644 --- a/bitbylaw/config.py +++ b/bitbylaw/config.py @@ -9,6 +9,7 @@ class Config: REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') REDIS_PORT = int(os.getenv('REDIS_PORT', '6379')) REDIS_DB_ADVOWARE_CACHE = int(os.getenv('REDIS_DB_ADVOWARE_CACHE', '1')) + REDIS_DB_CALENDAR_SYNC = int(os.getenv('REDIS_DB_CALENDAR_SYNC', '2')) REDIS_TIMEOUT_SECONDS = int(os.getenv('REDIS_TIMEOUT_SECONDS', '5')) # Advoware API settings @@ -22,4 +23,11 @@ class Config: ADVOWARE_ROLE = int(os.getenv('ADVOWARE_ROLE', '2')) ADVOWARE_PASSWORD = os.getenv('ADVOWARE_PASSWORD', 'your_password') ADVOWARE_TOKEN_LIFETIME_MINUTES = int(os.getenv('ADVOWARE_TOKEN_LIFETIME_MINUTES', '55')) - ADVOWARE_API_TIMEOUT_SECONDS = int(os.getenv('ADVOWARE_API_TIMEOUT_SECONDS', '30')) \ No newline at end of file + ADVOWARE_API_TIMEOUT_SECONDS = int(os.getenv('ADVOWARE_API_TIMEOUT_SECONDS', '30')) + + # Google Calendar API settings (Service Account only) + GOOGLE_CALENDAR_SERVICE_ACCOUNT_PATH = os.getenv('GOOGLE_CALENDAR_SERVICE_ACCOUNT_PATH', 'service-account.json') + GOOGLE_CALENDAR_SCOPES = ['https://www.googleapis.com/auth/calendar'] + + # Calendar Sync settings + CALENDAR_SYNC_ANONYMIZE_GOOGLE_EVENTS = os.getenv('CALENDAR_SYNC_ANONYMIZE_GOOGLE_EVENTS', 'true').lower() == 'true' \ No newline at end of file diff --git a/bitbylaw/motia-workbench.json b/bitbylaw/motia-workbench.json index 74662898..71c4b987 100644 --- a/bitbylaw/motia-workbench.json +++ b/bitbylaw/motia-workbench.json @@ -57,12 +57,29 @@ } } }, + { + "id": "advoware_cal_sync", + "config": { + "steps/advoware_cal_sync/calendar_sync_api_step.py": { + "x": 0, + "y": 0 + }, + "steps/advoware_cal_sync/calendar_sync_cron_step.py": { + "x": 200, + "y": 0 + }, + "steps/advoware_cal_sync/calendar_sync_event_step.py": { + "x": 100, + "y": 100 + } + } + }, { "id": "advoware", "config": { "steps/advoware_proxy/advoware_api_proxy_put_step.py": { - "x": 400, - "y": 0 + "x": 168, + "y": -54 }, "steps/advoware_proxy/advoware_api_proxy_post_step.py": { "x": -340, @@ -77,14 +94,5 @@ "y": 0 } } - }, - { - "id": "advoware_cal_sync", - "config": { - "steps/advoware_cal_sync/advoware_calendar_sync_step.py": { - "x": 0, - "y": 0 - } - } } ] \ No newline at end of file diff --git a/bitbylaw/requirements.txt b/bitbylaw/requirements.txt index fe6797b9..5e705310 100644 --- a/bitbylaw/requirements.txt +++ b/bitbylaw/requirements.txt @@ -5,6 +5,4 @@ requests redis python-dotenv google-api-python-client -google-auth -google-auth-oauthlib -google-auth-httplib2 \ No newline at end of file +google-auth \ No newline at end of file diff --git a/bitbylaw/services/advoware.py b/bitbylaw/services/advoware.py index 760376f0..544c498f 100644 --- a/bitbylaw/services/advoware.py +++ b/bitbylaw/services/advoware.py @@ -133,7 +133,7 @@ class AdvowareAPI: try: self._log(f"Making API call: {method} {url}") async with session.request(method, url, headers=effective_headers, params=params, json=json_data) as response: - self._log(f"API response status: {response.status}") + response.raise_for_status() if response.status == 401: self._log("401 Unauthorized, refreshing token") token = self.get_access_token(force_refresh=True) @@ -142,7 +142,19 @@ class AdvowareAPI: response.raise_for_status() return await response.json() if response.content_type == 'application/json' else None response.raise_for_status() - return await response.json() if response.content_type == 'application/json' else None + if response.content_type == 'application/json': + try: + return await response.json() + except Exception as e: + self._log(f"JSON parse error: {e}") + try: + text = await response.text() + self._log(f"Response text: {text}") + except Exception as text_e: + self._log(f"Error getting response text: {text_e}") + raise + else: + return None except aiohttp.ClientError as e: self._log(f"API call failed: {e}") raise diff --git a/bitbylaw/steps/advoware_cal_sync/advoware_calendar_sync_step.py b/bitbylaw/steps/advoware_cal_sync/advoware_calendar_sync_step.py deleted file mode 100644 index efdf6895..00000000 --- a/bitbylaw/steps/advoware_cal_sync/advoware_calendar_sync_step.py +++ /dev/null @@ -1,308 +0,0 @@ -from services.advoware import AdvowareAPI -from config import Config -from googleapiclient.discovery import build -from google.oauth2.credentials import Credentials -from google.auth.transport.requests import Request -from google_auth_oauthlib.flow import InstalledAppFlow -import json -import datetime -import pickle -import os.path -import redis - -config = { - 'type': 'api', - 'name': 'Advoware Calendar Sync', - 'description': 'Synchronisiert Advoware Termine mit Google Calendar für alle Mitarbeiter', - 'path': '/advoware/calendar/sync', - 'method': 'POST', - 'flows': ['advoware'], - 'emits': [] -} - -SCOPES = ['https://www.googleapis.com/auth/calendar'] - -async def get_google_service(context): - """Initialisiert Google Calendar API Service""" - creds = None - - # Token aus Datei laden falls vorhanden - if os.path.exists('token.pickle'): - with open('token.pickle', 'rb') as token: - creds = pickle.load(token) - - # Wenn keine validen Credentials, neu authentifizieren - if not creds or not creds.valid: - if creds and creds.expired and creds.refresh_token: - creds.refresh(Request()) - else: - # Hier würde normalerweise der OAuth Flow laufen - # Für Server-Umgebung brauchen wir Service Account oder gespeicherte Credentials - print("WARNING: Google OAuth Credentials nicht gefunden. Bitte token.pickle bereitstellen oder Google Calendar Sync überspringen.") - return None - - # Token speichern - with open('token.pickle', 'wb') as token: - pickle.dump(creds, token) - - return build('calendar', 'v3', credentials=creds) - -async def get_advoware_employees(context): - """Ruft alle Mitarbeiter von Advoware ab""" - advoware = AdvowareAPI(context) - try: - # Annahme: Mitarbeiter-Endpoint existiert ähnlich wie andere - result = await advoware.api_call('Mitarbeiter') - print(f"Advoware Mitarbeiter abgerufen: {len(result) if isinstance(result, list) else 'unbekannt'}") - return result if isinstance(result, list) else [] - except Exception as e: - print(f"Fehler beim Abrufen der Mitarbeiter: {e}") - return [] - -async def ensure_google_calendar(service, employee_kuerzel, context): - """Stellt sicher, dass ein Google Calendar für den Mitarbeiter existiert""" - calendar_name = f"AW-{employee_kuerzel}" - - try: - # Bestehende Kalender prüfen - calendar_list = service.calendarList().list().execute() - for calendar in calendar_list.get('items', []): - if calendar['summary'] == calendar_name: - print(f"Google Calendar '{calendar_name}' existiert bereits") - return calendar['id'] - - # Neuen Kalender erstellen - calendar_body = { - 'summary': calendar_name, - 'description': f'Advoware Termine für Mitarbeiter {employee_kuerzel}', - 'timeZone': 'Europe/Berlin' - } - - created_calendar = service.calendars().insert(body=calendar_body).execute() - calendar_id = created_calendar['id'] - print(f"Google Calendar '{calendar_name}' erstellt mit ID: {calendar_id}") - - return calendar_id - - except Exception as e: - print(f"Fehler bei Google Calendar für {employee_kuerzel}: {e}") - return None - -async def get_advoware_appointments(employee_kuerzel, context): - """Ruft Termine eines Mitarbeiters aus Advoware ab""" - advoware = AdvowareAPI(context) - - # Zeitraum: aktuelles Jahr + 2 Jahre - from_date = datetime.datetime.now().strftime('%Y-01-01T00:00:00Z') - to_date = (datetime.datetime.now() + datetime.timedelta(days=730)).strftime('%Y-12-31T23:59:59Z') - - try: - params = { - 'kuerzel': employee_kuerzel, - 'from': from_date, - 'to': to_date - } - result = await advoware.api_call('Termine', method='GET', params=params) - appointments = result if isinstance(result, list) else [] - print(f"Advoware Termine für {employee_kuerzel}: {len(appointments)} gefunden") - return appointments - except Exception as e: - print(f"Fehler beim Abrufen der Termine für {employee_kuerzel}: {e}") - return [] - -async def get_google_events(service, calendar_id, context): - """Ruft Events aus Google Calendar ab""" - try: - now = datetime.datetime.utcnow() - from_date = now.strftime('%Y-01-01T00:00:00Z') - to_date = (now + datetime.timedelta(days=730)).strftime('%Y-12-31T23:59:59Z') - - events_result = service.events().list( - calendarId=calendar_id, - timeMin=from_date, - timeMax=to_date, - singleEvents=True, - orderBy='startTime' - ).execute() - - events = events_result.get('items', []) - print(f"Google Calendar Events: {len(events)} gefunden") - return events - except Exception as e: - print(f"Fehler beim Abrufen der Google Events: {e}") - return [] - -async def sync_appointment_to_google(service, calendar_id, appointment, full_content, context): - """Synchronisiert einen Advoware-Termin zu Google Calendar""" - try: - # Start- und Endzeit aus Advoware-Daten - start_date = appointment.get('datum') - end_date = appointment.get('datumBis') or start_date - start_time = appointment.get('uhrzeitBis', '00:00:00') # Advoware hat uhrzeitBis als Endzeit? - end_time = appointment.get('uhrzeitBis', '23:59:59') - - # Vollständiges Event oder nur "blocked" - if full_content: - summary = appointment.get('text', 'Advoware Termin') - description = f"Advoware Termin\nNotiz: {appointment.get('notiz', '')}\nOrt: {appointment.get('ort', '')}\nRaum: {appointment.get('raum', '')}" - location = appointment.get('ort', '') - else: - summary = "Blocked (Advoware)" - description = "Termin aus Advoware" - location = "" - - event_body = { - 'summary': summary, - 'description': description, - 'location': location, - 'start': { - 'dateTime': f"{start_date}T{start_time}", - 'timeZone': 'Europe/Berlin', - }, - 'end': { - 'dateTime': f"{end_date}T{end_time}", - 'timeZone': 'Europe/Berlin', - }, - 'extendedProperties': { - 'private': { - 'advoware_frnr': str(appointment.get('frNr')) - } - } - } - - # Event erstellen - created_event = service.events().insert(calendarId=calendar_id, body=event_body).execute() - print(f"Termin {appointment.get('frNr')} zu Google Calendar hinzugefügt") - return created_event - - except Exception as e: - print(f"Fehler beim Sync zu Google für Termin {appointment.get('frNr')}: {e}") - return None - -async def sync_event_to_advoware(service, calendar_id, event, employee_kuerzel, context): - """Synchronisiert ein Google Event zu Advoware (falls keine frNr vorhanden)""" - try: - # Prüfen ob bereits eine frNr vorhanden - extended_props = event.get('extendedProperties', {}).get('private', {}) - frnr = extended_props.get('advoware_frnr') - - if frnr: - # Bereits synchronisiert - return None - - # Neuen Termin in Advoware erstellen - advoware = AdvowareAPI(context) - - # Start/End aus Google Event extrahieren - start = event.get('start', {}).get('dateTime', '') - end = event.get('end', {}).get('dateTime', '') - - # Advoware-Termin erstellen - appointment_data = { - 'text': event.get('summary', 'Google Calendar Termin'), - 'notiz': event.get('description', ''), - 'ort': event.get('location', ''), - 'datum': start[:10] if start else datetime.datetime.now().strftime('%Y-%m-%d'), - 'uhrzeitBis': start[11:19] if start else '09:00:00', - 'datumBis': end[:10] if end else start[:10] if start else datetime.datetime.now().strftime('%Y-%m-%d'), - 'sb': employee_kuerzel, - 'anwalt': employee_kuerzel - } - - result = await advoware.api_call('Termine', method='POST', json_data=appointment_data) - - if result and isinstance(result, dict): - new_frnr = result.get('frNr') - if new_frnr: - # frNr zurück in Google Event schreiben - event['extendedProperties'] = event.get('extendedProperties', {}) - event['extendedProperties']['private'] = event['extendedProperties'].get('private', {}) - event['extendedProperties']['private']['advoware_frnr'] = str(new_frnr) - - service.events().update(calendarId=calendar_id, eventId=event['id'], body=event).execute() - print(f"Neuer Advoware Termin erstellt: {new_frnr}, frNr in Google aktualisiert") - return new_frnr - - except Exception as e: - print(f"Fehler beim Sync zu Advoware für Google Event {event.get('id')}: {e}") - return None - -async def handler(req, context): - try: - # Konfiguration aus Request-Body - body = req.get('body', {}) - full_content = body.get('full_content', True) # Default: volle Termindetails - - print(f"Starte Advoware Calendar Sync, full_content: {full_content}") - - # Google Calendar Service initialisieren - service = await get_google_service(context) - if not service: - print("Google Calendar Service nicht verfügbar. Sync wird übersprungen.") - return { - 'status': 200, - 'body': { - 'status': 'skipped', - 'reason': 'Google Calendar credentials not configured', - 'total_synced': 0 - } - } - - # Alle Mitarbeiter abrufen - employees = await get_advoware_employees(context) - - if not employees: - return {'status': 500, 'body': {'error': 'Keine Mitarbeiter gefunden'}} - - total_synced = 0 - - for employee in employees: - kuerzel = employee.get('kuerzel') or employee.get('anwalt') - if not kuerzel: - print(f"Mitarbeiter ohne Kürzel übersprungen: {employee}") - continue - - print(f"Verarbeite Mitarbeiter: {kuerzel}") - - # Google Calendar sicherstellen - calendar_id = await ensure_google_calendar(service, kuerzel, context) - if not calendar_id: - continue - - # Termine aus beiden Systemen abrufen - advoware_appointments = await get_advoware_appointments(kuerzel, context) - google_events = await get_google_events(service, calendar_id, context) - - # Advoware → Google syncen - google_frnrs = {event.get('extendedProperties', {}).get('private', {}).get('advoware_frnr') for event in google_events} - - for appointment in advoware_appointments: - frnr = str(appointment.get('frNr')) - if frnr not in google_frnrs: - await sync_appointment_to_google(service, calendar_id, appointment, full_content, context) - total_synced += 1 - - # Google → Advoware syncen - for event in google_events: - await sync_event_to_advoware(service, calendar_id, event, kuerzel, context) - - print(f"Advoware Calendar Sync abgeschlossen. {total_synced} Termine synchronisiert.") - - return { - 'status': 200, - 'body': { - 'status': 'completed', - 'total_synced': total_synced, - 'employees_processed': len([e for e in employees if e.get('kuerzel') or e.get('anwalt')]) - } - } - - except Exception as e: - print(f"Fehler beim Advoware Calendar Sync: {e}") - return { - 'status': 500, - 'body': { - 'error': 'Internal server error', - 'details': str(e) - } - } \ No newline at end of file diff --git a/bitbylaw/steps/advoware_cal_sync/calendar_sync_api_step.py b/bitbylaw/steps/advoware_cal_sync/calendar_sync_api_step.py index 19770578..3fa38491 100644 --- a/bitbylaw/steps/advoware_cal_sync/calendar_sync_api_step.py +++ b/bitbylaw/steps/advoware_cal_sync/calendar_sync_api_step.py @@ -1,4 +1,8 @@ import json +import redis +from config import Config + +CALENDAR_SYNC_LOCK_KEY = 'calendar_sync_lock' config = { 'type': 'api', @@ -11,11 +15,34 @@ config = { async def handler(req, context): try: + # Prüfe ob bereits ein Sync läuft + redis_client = redis.Redis( + host=Config.REDIS_HOST, + port=int(Config.REDIS_PORT), + db=int(Config.REDIS_DB_CALENDAR_SYNC), + socket_timeout=Config.REDIS_TIMEOUT_SECONDS + ) + + if redis_client.get(CALENDAR_SYNC_LOCK_KEY): + context.logger.info("Calendar Sync API: Sync bereits aktiv, überspringe") + return { + 'status': 409, + 'body': { + 'status': 'conflict', + 'message': 'Calendar sync bereits aktiv', + 'triggered_by': 'api' + } + } + # Konfiguration aus Request-Body body = req.get('body', {}) full_content = body.get('full_content', True) - print(f"Calendar Sync API aufgerufen, full_content: {full_content}") + context.logger.info(f"Calendar Sync API aufgerufen, full_content: {full_content}") + + # Setze Lock für 30 Minuten (Sync sollte max 30 Minuten dauern) + redis_client.set(CALENDAR_SYNC_LOCK_KEY, 'api', ex=1800) + context.logger.info("Calendar Sync API: Lock gesetzt") # Emit Event für den Sync await context.emit({ diff --git a/bitbylaw/steps/advoware_cal_sync/calendar_sync_cron_step.py b/bitbylaw/steps/advoware_cal_sync/calendar_sync_cron_step.py index dd8ce99d..841d98d9 100644 --- a/bitbylaw/steps/advoware_cal_sync/calendar_sync_cron_step.py +++ b/bitbylaw/steps/advoware_cal_sync/calendar_sync_cron_step.py @@ -1,4 +1,8 @@ import json +import redis +from config import Config + +CALENDAR_SYNC_LOCK_KEY = 'calendar_sync_lock' config = { 'type': 'cron', @@ -10,7 +14,25 @@ config = { async def handler(event, context): try: - context.logger.info("Calendar Sync Cron: Starte automatische Synchronisation alle 15 Minuten") + # Prüfe ob bereits ein Sync läuft + redis_client = redis.Redis( + host=Config.REDIS_HOST, + port=int(Config.REDIS_PORT), + db=int(Config.REDIS_DB_CALENDAR_SYNC), + socket_timeout=Config.REDIS_TIMEOUT_SECONDS + ) + + if redis_client.get(CALENDAR_SYNC_LOCK_KEY): + context.logger.info("Calendar Sync Cron: Sync bereits aktiv, überspringe") + return { + 'status': 'skipped', + 'reason': 'sync_already_running', + 'triggered_by': 'cron' + } + + # Setze Lock für 30 Minuten (Sync sollte max 30 Minuten dauern) + redis_client.set(CALENDAR_SYNC_LOCK_KEY, 'cron', ex=1800) + context.logger.info("Calendar Sync Cron: Lock gesetzt, starte automatische Synchronisation alle 15 Minuten") # Emit Event für den Sync await context.emit({ 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 9ca84bad..f3a5a6ca 100644 --- a/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py +++ b/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py @@ -1,684 +1,477 @@ -from services.advoware import AdvowareAPI -from config import Config -from googleapiclient.discovery import build -from google.oauth2 import service_account -from googleapiclient.errors import HttpError -import json -import datetime -import redis -import os -import hashlib import asyncio -import random +import logging +import os +import datetime +from datetime import timedelta +import pytz +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from google.oauth2 import service_account +import asyncpg +from config import Config # Assuming Config has POSTGRES_HOST='localhost', USER, PASSWORD, DB_NAME, GOOGLE_CALENDAR_SERVICE_ACCOUNT_PATH, GOOGLE_CALENDAR_SCOPES, etc. +from services.advoware import AdvowareAPI # Assuming this is the existing wrapper for Advoware API calls -# Salt für Token-Generierung laden -CALENDAR_SYNC_SALT = os.getenv('CALENDAR_SYNC_SALT') -if not CALENDAR_SYNC_SALT: - raise ValueError("CALENDAR_SYNC_SALT environment variable not set") +# Setup logging +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +handler = logging.StreamHandler() +logger.addHandler(handler) -def generate_change_token(frnr): - """Generiert einen Change-Token mit Salt für eine frNr""" - return hashlib.md5((str(frnr) + CALENDAR_SYNC_SALT).encode()).hexdigest() +# Timezone for all operations (optimize TZ handling) +BERLIN_TZ = pytz.timezone('Europe/Berlin') -config = { - 'type': 'event', - 'name': 'Calendar Sync Event Handler', - 'description': 'Führt den Advoware-Google Calendar Sync aus bei Events', - 'subscribes': ['calendar.sync.triggered'], - 'emits': [] -} +# Constants for ranges (optimize fetch efficiency) +FETCH_FROM = (datetime.datetime.now(BERLIN_TZ) - timedelta(days=365)).strftime('%Y-01-01T00:00:00') +FETCH_TO = (datetime.datetime.now(BERLIN_TZ) + timedelta(days=730)).strftime('%Y-12-31T23:59:59') -async def google_api_call_with_backoff(call_func, *args, **kwargs): - """Führt Google API Call mit exponentiellem Backoff bei 403/429 aus""" - max_retries = 5 - base_delay = 1 # seconds +# Relevant fields for data comparison (simple diff) +COMPARISON_FIELDS = ['text', 'notiz', 'ort', 'datum', 'uhrzeitBis', 'datumBis', 'dauertermin', 'turnus', 'turnusArt'] - for attempt in range(max_retries): - try: - return call_func(*args, **kwargs) - except HttpError as e: - if e.resp.status in [403, 429]: - if attempt == max_retries - 1: - raise - delay = base_delay * (2 ** attempt) + random.uniform(0, 1) - await asyncio.sleep(delay) - else: - raise +async def connect_db(): + """Connect to Postgres DB from Config.""" + try: + 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 + ) + return conn + except Exception as e: + logger.error(f"Failed to connect to DB: {e}") + raise -SCOPES = Config.GOOGLE_CALENDAR_SCOPES - -async def get_google_service(context): - """Initialisiert Google Calendar API Service mit Service Account""" +async def get_google_service(): + """Initialize Google Calendar service.""" try: service_account_path = Config.GOOGLE_CALENDAR_SERVICE_ACCOUNT_PATH - if not os.path.exists(service_account_path): - context.logger.error(f"Service Account Datei nicht gefunden: {service_account_path}") - context.logger.error("Bitte erstellen Sie einen Google Service Account und legen Sie die service-account.json in das Projekt-Verzeichnis.") - return None - - context.logger.info("Initialisiere Google Calendar API mit Service Account...") + raise FileNotFoundError(f"Service account file not found: {service_account_path}") creds = service_account.Credentials.from_service_account_file( - service_account_path, scopes=SCOPES) - + service_account_path, scopes=Config.GOOGLE_CALENDAR_SCOPES + ) service = build('calendar', 'v3', credentials=creds) - context.logger.info("Google Calendar API erfolgreich initialisiert") return service - except Exception as e: - context.logger.error(f"Fehler bei Google Service Account Authentifizierung: {e}") - context.logger.error("Überprüfen Sie die service-account.json Datei und die Berechtigungen.") - return None + logger.error(f"Failed to initialize Google service: {e}") + raise -async def get_advoware_employees(context): - """Ruft alle Mitarbeiter von Advoware ab""" - advoware = AdvowareAPI(context) - try: - # Annahme: Mitarbeiter-Endpoint existiert ähnlich wie andere - result = await advoware.api_call('api/v1/advonet/Mitarbeiter', params={'aktiv': 'true'}) - context.logger.info(f"Advoware Mitarbeiter abgerufen: {len(result) if isinstance(result, list) else 'unbekannt'}") - return result if isinstance(result, list) else [] - except Exception as e: - context.logger.error(f"Fehler beim Abrufen der Mitarbeiter: {e}") - return [] - -async def ensure_google_calendar(service, employee_kuerzel, context): - """Stellt sicher, dass ein Google Calendar für den Mitarbeiter existiert""" +async def ensure_google_calendar(service, employee_kuerzel): + """Ensure Google Calendar exists for employee.""" calendar_name = f"AW-{employee_kuerzel}" - try: - # Bestehende Kalender prüfen - calendar_list = await google_api_call_with_backoff(service.calendarList().list().execute) + calendar_list = service.calendarList().list().execute() for calendar in calendar_list.get('items', []): if calendar['summary'] == calendar_name: - context.logger.info(f"Google Calendar '{calendar_name}' existiert bereits") - calendar_id = calendar['id'] - - # Kalender mit Hauptaccount teilen (auch für bestehende) - acl_rule = { - 'scope': { - 'type': 'user', - 'value': 'lehmannundpartner@gmail.com' - }, - 'role': 'owner' - } - try: - await google_api_call_with_backoff(service.acl().insert(calendarId=calendar_id, body=acl_rule).execute) - context.logger.info(f"Kalender '{calendar_name}' mit lehmannundpartner@gmail.com geteilt") - except Exception as e: - context.logger.info(f"ACL für '{calendar_name}' bereits vorhanden oder Fehler: {e}") - - return calendar_id - - # Neuen Kalender erstellen + return calendar['id'] + # Create new calendar_body = { 'summary': calendar_name, - 'description': f'Advoware Termine für Mitarbeiter {employee_kuerzel}', 'timeZone': 'Europe/Berlin' } - - created_calendar = await google_api_call_with_backoff(service.calendars().insert(body=calendar_body).execute) - calendar_id = created_calendar['id'] - context.logger.info(f"Google Calendar '{calendar_name}' erstellt mit ID: {calendar_id}") - - # Kalender mit Hauptaccount teilen + created = service.calendars().insert(body=calendar_body).execute() + calendar_id = created['id'] + # Share with main account if needed acl_rule = { - 'scope': { - 'type': 'user', - 'value': 'lehmannundpartner@gmail.com' - }, + 'scope': {'type': 'user', 'value': 'lehmannundpartner@gmail.com'}, 'role': 'owner' } - await google_api_call_with_backoff(service.acl().insert(calendarId=calendar_id, body=acl_rule).execute) - context.logger.info(f"Kalender '{calendar_name}' mit lehmannundpartner@gmail.com geteilt") - + service.acl().insert(calendarId=calendar_id, body=acl_rule).execute() return calendar_id - + except HttpError as e: + logger.error(f"Google API error for calendar {employee_kuerzel}: {e}") + raise except Exception as e: - context.logger.error(f"Fehler bei Google Calendar für {employee_kuerzel}: {e}") - return None - -async def get_advoware_appointments(employee_kuerzel, context): - """Ruft Termine eines Mitarbeiters aus Advoware ab""" - advoware = AdvowareAPI(context) - - # Zeitraum: aktuelles Jahr + 2 Jahre - from_date = datetime.datetime.now().strftime('%Y-01-01T00:00:00Z') - to_date = (datetime.datetime.now() + datetime.timedelta(days=730)).strftime('%Y-12-31T23:59:59Z') + logger.error(f"Failed to ensure Google calendar for {employee_kuerzel}: {e}") + raise +async def fetch_advoware_appointments(advoware, employee_kuerzel): + """Fetch Advoware appointments in range.""" try: params = { 'kuerzel': employee_kuerzel, - 'from': from_date, - 'to': to_date + 'from': FETCH_FROM, + 'to': FETCH_TO } result = await advoware.api_call('api/v1/advonet/Termine', method='GET', params=params) appointments = result if isinstance(result, list) else [] - context.logger.info(f"Advoware Termine für {employee_kuerzel}: {len(appointments)} gefunden") + logger.info(f"Fetched {len(appointments)} Advoware appointments for {employee_kuerzel}") return appointments except Exception as e: - context.logger.error(f"Fehler beim Abrufen der Termine für {employee_kuerzel}: {e}") - return [] + logger.error(f"Failed to fetch Advoware appointments: {e}") + raise -async def get_google_events(service, calendar_id, context): - """Ruft Events aus Google Calendar ab""" +async def fetch_google_events(service, calendar_id): + """Fetch Google events in range.""" try: - now = datetime.datetime.utcnow() - from_date = now.strftime('%Y-01-01T00:00:00Z') - to_date = (now + datetime.timedelta(days=730)).strftime('%Y-12-31T23:59:59Z') - - events_result = await google_api_call_with_backoff(service.events().list( + now = datetime.datetime.now(pytz.utc) + from_date = now - timedelta(days=365) + to_date = now + timedelta(days=730) + events_result = service.events().list( calendarId=calendar_id, - timeMin=from_date, - timeMax=to_date, - singleEvents=True, + timeMin=from_date.isoformat() + 'Z', + timeMax=to_date.isoformat() + 'Z', + singleEvents=True, # Expand recurring orderBy='startTime' - ).execute) - - events = events_result.get('items', []) - context.logger.info(f"Google Calendar Events: {len(events)} gefunden") + ).execute() + events = [evt for evt in events_result.get('items', []) if evt.get('status') != 'cancelled'] + logger.info(f"Fetched {len(events)} Google events for calendar {calendar_id}") return events + except HttpError as e: + logger.error(f"Google API error fetching events: {e}") + raise except Exception as e: - context.logger.error(f"Fehler beim Abrufen der Google Events: {e}") - return [] + logger.error(f"Failed to fetch Google events: {e}") + raise -async def sync_appointment_to_google(service, calendar_id, appointment, full_content, context): - """Synchronisiert einen Advoware-Termin zu Google Calendar""" - try: - context.logger.info(f"Sync Advoware Termin {appointment.get('frNr')} zu Google starten") - context.logger.debug(f"Termin Daten: {appointment}") - - # Advoware sendet bereits datetime strings - datum = appointment.get('datum', '') # z.B. '2025-05-30T16:30:00' - datum_bis = appointment.get('datumBis', '') # z.B. '2025-05-30T00:00:00' - - # Extrahiere Datum und Zeit separat - if 'T' in datum: - start_date = datum.split('T')[0] # '2025-05-30' - start_time = datum.split('T')[1] # '16:30:00' +def standardize_appointment_data(data, source): + """Standardize data from Advoware or Google to comparable dict, with TZ handling.""" + if source == 'advoware': + start_str = data.get('datum', '') + end_str = data.get('datumBis', data.get('datum', '')) + start_time = data.get('uhrzeitVon') or '09:00:00' if 'T' not in start_str else start_str.split('T')[1] + end_time = data.get('uhrzeitBis', '10:00:00') + start_dt = BERLIN_TZ.localize(datetime.datetime.fromisoformat(start_str.replace('Z', ''))) + end_dt = BERLIN_TZ.localize(datetime.datetime.fromisoformat(end_str.replace('Z', ''))) + return { + 'start': start_dt, + 'end': end_dt, + 'text': data.get('text', ''), + 'notiz': data.get('notiz', ''), + 'ort': data.get('ort', ''), + 'dauertermin': data.get('dauertermin', 0), + 'turnus': data.get('turnus', 0), + 'turnusArt': data.get('turnusArt', 0), + 'recurrence': None # No RRULE in Advoware + } + elif source == 'google': + start_obj = data.get('start', {}) + end_obj = data.get('end', {}) + if 'dateTime' in start_obj: + start_dt = datetime.datetime.fromisoformat(start_obj['dateTime'].rstrip('Z')).astimezone(BERLIN_TZ) + all_day = False else: - start_date = datum - start_time = appointment.get('uhrzeitVon') or appointment.get('uhrzeit') or '09:00:00' - - if 'T' in datum_bis and datum_bis != '0001-01-01T00:00:00': # Advoware default - end_date = datum_bis.split('T')[0] - end_time = datum_bis.split('T')[1] - # Prüfen ob Endzeit 00:00:00 ist (Advoware default für ganztägige Termine) - if end_time == '00:00:00': - # Berechne Endzeit als Startzeit + 1 Stunde - start_hour = int(start_time.split(':')[0]) - end_hour = (start_hour + 1) % 24 - end_time = f"{end_hour:02d}:{start_time.split(':')[1]}:{start_time.split(':')[2]}" + start_dt = BERLIN_TZ.localize(datetime.datetime.fromisoformat(start_obj['date'])) + all_day = True + if 'dateTime' in end_obj: + end_dt = datetime.datetime.fromisoformat(end_obj['dateTime'].rstrip('Z')).astimezone(BERLIN_TZ) else: - end_date = start_date - # Standarddauer: 1 Stunde - start_hour = int(start_time.split(':')[0]) - end_hour = (start_hour + 1) % 24 - end_time = f"{end_hour:02d}:{start_time.split(':')[1]}:{start_time.split(':')[2]}" - - # Vollständiges Event oder nur "blocked" - immer "blocked" - summary = "Advoware blocked" - description = f"Advoware Termin ID: {appointment.get('frNr')}" - location = "" - - # Google Calendar erwartet RFC3339 formatierte Datetimes - start_datetime = f"{start_date}T{start_time}+01:00" # +01:00 für Europe/Berlin - end_datetime = f"{end_date}T{end_time}+01:00" - - context.logger.info(f"Erstelle Google Event: Start {start_datetime}, End {end_datetime}") - - event_body = { - 'summary': summary, - 'description': description, - 'location': location, - 'start': { - 'dateTime': start_datetime, - 'timeZone': 'Europe/Berlin', - }, - 'end': { - 'dateTime': end_datetime, - 'timeZone': 'Europe/Berlin', - }, - 'extendedProperties': { - 'private': { - 'advoware_frnr': str(appointment.get('frNr')) - } - } + end_dt = BERLIN_TZ.localize(datetime.datetime.fromisoformat(end_obj['date'])) + dauertermin = 1 if all_day or (end_dt - start_dt).days > 1 else 0 + return { + 'start': start_dt, + 'end': end_dt, + 'text': data.get('summary', ''), + 'notiz': data.get('description', ''), + 'ort': data.get('location', ''), + 'dauertermin': dauertermin, + 'turnus': 1 if data.get('recurrence') else 0, # Simplified + 'turnusArt': 0, # Map RRULE to type if needed + 'recurrence': data.get('recurrence') } - # Event erstellen - context.logger.info("Sende Google Calendar Insert Request...") - created_event = await google_api_call_with_backoff(service.events().insert(calendarId=calendar_id, body=event_body).execute) - context.logger.info(f"Termin {appointment.get('frNr')} zu Google Calendar hinzugefügt, Event ID: {created_event.get('id')}") - return created_event +def data_diff(data1, data2): + """Simple diff on standardized data.""" + if not data1 or not data2: + return True + for field in COMPARISON_FIELDS: + if data1.get(field) != data2.get(field): + return True + return False - except Exception as e: - context.logger.error(f"Fehler beim Sync zu Google für Termin {appointment.get('frNr')}: {e}") - return None - -async def sync_event_to_advoware(service, calendar_id, event, employee_kuerzel, context): - """Synchronisiert ein Google Event zu Advoware (nur wenn change_allowed_token validiert)""" +async def create_advoware_appointment(advoware, data, employee_kuerzel): + """Create Advoware appointment from standardized data.""" + start_dt = data['start'].astimezone(BERLIN_TZ) + end_dt = data['end'].astimezone(BERLIN_TZ) + appointment_data = { + 'text': data['text'], + 'notiz': data['notiz'], + 'ort': data['ort'], + 'datum': start_dt.strftime('%Y-%m-%dT%H:%M:%S'), + 'uhrzeitBis': end_dt.strftime('%H:%M:%S'), + 'datumBis': end_dt.strftime('%Y-%m-%dT%H:%M:%S'), + 'sb': employee_kuerzel, + 'anwalt': employee_kuerzel, + 'vorbereitungsDauer': '00:00:00', + 'dauertermin': data['dauertermin'], + 'turnus': data['turnus'], + 'turnusArt': data['turnusArt'] + } try: - context.logger.info(f"Sync Google Event {event.get('id')} zu Advoware starten: {event.get('summary')}") - context.logger.debug(f"Event Daten: {event}") - - # Prüfen ob bereits eine frNr vorhanden (aus description parsen) - description = event.get('description', '') - frnr = None - change_token = None - if 'frNr: ' in description and 'sync-token: ' in description: - frnr_parts = description.split('frNr: ') - if len(frnr_parts) > 1: - frnr = frnr_parts[1].split('\n')[0] - token_parts = description.split('sync-token: ') - if len(token_parts) > 1: - change_token = token_parts[1].split('\n')[0] - context.logger.info(f"Parsed frNr: {frnr}, Token: {change_token}") - - if frnr and change_token: - # Validierung des Tokens - expected_token = generate_change_token(frnr) - if change_token != expected_token: - context.logger.info(f"Token für Google Event {event.get('id')} nicht validiert, überspringe Update") - return None - - # Termin aus Advoware abrufen zum Vergleich - advoware = AdvowareAPI(context) - result = await advoware.api_call('api/v1/advonet/Termine', params={'frnr': frnr}) - existing_appointment = result[0] if result and isinstance(result, list) and len(result) > 0 else None - if not existing_appointment: - context.logger.error(f"Termin {frnr} nicht in Advoware gefunden") - return None - - # Daten aus Google Event extrahieren - start = event.get('start', {}).get('dateTime') or event.get('start', {}).get('date', '') - end = event.get('end', {}).get('dateTime') or event.get('end', {}).get('date', '') - summary = event.get('summary', '') - description = event.get('description', '') - location = event.get('location', '') - - # Mit Advoware-Daten vergleichen - advoware_datum = existing_appointment.get('datum', '') - advoware_uhrzeit_bis = existing_appointment.get('uhrzeitBis', '') - advoware_text = existing_appointment.get('text', '') - advoware_notiz = existing_appointment.get('notiz', '') - advoware_ort = existing_appointment.get('ort', '') - - # Berechne erwartete Werte aus Google - expected_datum = start.split('+')[0] if '+' in start else start - expected_uhrzeit_bis = end.split('T')[1].split('+')[0] if 'T' in end else '09:00:00' - # Für all-day Events (nur date, keine Zeit) setze Default-Start-Zeit - if 'T' not in start: - expected_datum = f"{start}T09:00:00" - context.logger.info(f"All-day Event erkannt, setze Default-Start-Zeit auf 09:00:00") - expected_text = summary - expected_notiz = description - expected_ort = location - - context.logger.debug(f"Vergleiche: Advoware datum={advoware_datum}, expected={expected_datum}") - context.logger.debug(f"Vergleiche: Advoware uhrzeit_bis={advoware_uhrzeit_bis}, expected={expected_uhrzeit_bis}") - context.logger.debug(f"Vergleiche: Advoware text={advoware_text}, expected={expected_text}") - context.logger.debug(f"Vergleiche: Advoware notiz={advoware_notiz}, expected={expected_notiz}") - context.logger.debug(f"Vergleiche: Advoware ort={advoware_ort}, expected={expected_ort}") - - if (advoware_datum == expected_datum and - advoware_uhrzeit_bis == expected_uhrzeit_bis and - advoware_text == expected_text and - advoware_notiz == expected_notiz and - advoware_ort == expected_ort): - context.logger.info(f"Google Event {event.get('id')} unverändert, überspringe Advoware Update") - return None - - context.logger.info(f"Termin {frnr} geändert, aktualisiere in Advoware...") - - # Update Termin in Advoware - # Kein Anhängen von sync_info bei bestehenden Terminen - updated_description = description - - appointment_data = { - 'frNr': int(frnr), - 'text': summary, - 'notiz': updated_description, - 'ort': location, - 'datum': start.split('+')[0] if '+' in start else start, - 'uhrzeitBis': end.split('T')[1].split('+')[0] if 'T' in end else '09:00:00', - 'datumBis': end.split('+')[0] if '+' in end else end, - 'sb': employee_kuerzel, - 'anwalt': employee_kuerzel, - 'vorbereitungsDauer': '00:00:00' - } - await update_advoware_appointment(advoware, frnr, change_token, appointment_data, context) - return frnr - else: - context.logger.info(f"Neuer Termin aus Google, erstelle in Advoware...") - # Neuen Termin in Advoware erstellen - advoware = AdvowareAPI(context) - - # Start/End aus Google Event extrahieren - start = event.get('start', {}).get('dateTime') or event.get('start', {}).get('date', '') - end = event.get('end', {}).get('dateTime') or event.get('end', {}).get('date', '') - - # Advoware-Termin erstellen - appointment_data = { - 'text': event.get('summary', 'Google Calendar Termin'), - 'notiz': event.get('description', ''), - 'ort': event.get('location', ''), - 'datum': start.split('+')[0] if '+' in start else start, - 'uhrzeitBis': end.split('T')[1].split('+')[0] if 'T' in end else '09:00:00', - 'datumBis': end.split('+')[0] if '+' in end else end, - 'sb': employee_kuerzel, - 'anwalt': employee_kuerzel, - 'vorbereitungsDauer': '00:00:00' - } - - context.logger.info("Sende Advoware POST Request...") - result = await advoware.api_call('api/v1/advonet/Termine', method='POST', json_data=appointment_data) - - context.logger.info(f"POST Termine Response: {result}") - - if result and isinstance(result, dict): - new_frnr = result.get('frNr') or result.get('frnr') - if new_frnr: - context.logger.info(f"Neuer Advoware Termin erstellt: {new_frnr}") - # Sync-Token berechnen - token = generate_change_token(new_frnr) - - # Sync-Info für beide Systeme vorbereiten - sync_info = f"\n\n## no change below this line ##\nfrNr: {new_frnr}\nsync-token: {token}" - existing_description = event.get('description', '') - new_description = existing_description + sync_info - - # Sofort frNr und sync-token in Advoware schreiben (PUT) - context.logger.info(f"Schreibe sync-info in Advoware Termin {new_frnr}...") - update_data = {'frNr': int(new_frnr), 'notiz': new_description} - await advoware.api_call('api/v1/advonet/Termine', method='PUT', json_data=update_data) - context.logger.info(f"Sync-info in Advoware eingefügt") - - # Sofort frNr und sync-token in Google schreiben - context.logger.info(f"Schreibe sync-info in Google Event {event.get('id')}...") - event['description'] = new_description - - await google_api_call_with_backoff(service.events().update(calendarId=calendar_id, eventId=event['id'], body=event).execute) - context.logger.info(f"Sync-info in Google aktualisiert") - - return new_frnr - else: - context.logger.error(f"Keine frNr in POST Response: {result}") - else: - context.logger.error(f"Ungültige POST Response: {result}") - + result = await advoware.api_call('api/v1/advonet/Termine', method='POST', json_data=appointment_data) + frnr = str(result.get('frNr')) + logger.info(f"Created Advoware appointment frNr: {frnr}") + return frnr except Exception as e: - context.logger.error(f"Fehler beim Sync zu Advoware für Google Event {event.get('id')}: {e}") - return None + logger.error(f"Failed to create Advoware appointment: {e}") + raise -async def update_google_event(service, calendar_id, existing_event, appointment, context): - """Aktualisiert ein bestehendes Google Event mit Advoware-Daten, nur wenn Änderungen vorliegen""" +async def update_advoware_appointment(advoware, frnr, data): + """Update Advoware appointment.""" + start_dt = data['start'].astimezone(BERLIN_TZ) + end_dt = data['end'].astimezone(BERLIN_TZ) + appointment_data = { + 'frNr': int(frnr), + 'text': data['text'], + 'notiz': data['notiz'], + 'ort': data['ort'], + 'datum': start_dt.strftime('%Y-%m-%dT%H:%M:%S'), + 'uhrzeitBis': end_dt.strftime('%H:%M:%S'), + 'datumBis': end_dt.strftime('%Y-%m-%dT%H:%M:%S'), + 'dauertermin': data['dauertermin'], + 'turnus': data['turnus'], + 'turnusArt': data['turnusArt'] + } try: - context.logger.info(f"Aktualisiere Google Event für Advoware Termin {appointment.get('frNr')}") - context.logger.debug(f"Appointment Daten: {appointment}") - - # Gleiche Logik wie in sync_appointment_to_google für datetime und body - datum = appointment.get('datum', '') - datum_bis = appointment.get('datumBis', '') - - if 'T' in datum: - start_date = datum.split('T')[0] - start_time = datum.split('T')[1] - else: - start_date = datum - start_time = appointment.get('uhrzeitVon') or appointment.get('uhrzeit') or '09:00:00' - - if 'T' in datum_bis and datum_bis != '0001-01-01T00:00:00': - end_date = datum_bis.split('T')[0] - end_time = datum_bis.split('T')[1] - if end_time == '00:00:00': - start_hour = int(start_time.split(':')[0]) - end_hour = (start_hour + 1) % 24 - end_time = f"{end_hour:02d}:{start_time.split(':')[1]}:{start_time.split(':')[2]}" - else: - end_date = start_date - start_hour = int(start_time.split(':')[0]) - end_hour = (start_hour + 1) % 24 - end_time = f"{end_hour:02d}:{start_time.split(':')[1]}:{start_time.split(':')[2]}" - - summary = "Advoware blocked" - description = f"Advoware Termin ID: {appointment.get('frNr')}" - location = "" - - start_datetime = f"{start_date}T{start_time}+01:00" - end_datetime = f"{end_date}T{end_time}+01:00" - - # Prüfe ob es ein Google-initiales Event ist (hat sync-token in description) - is_google_initial = 'sync-token: ' in existing_event.get('description', '') - if is_google_initial: - # Behalte die originale Summary - summary = existing_event.get('summary', summary) - context.logger.debug(f"Google-initiales Event, behalte Summary: {summary}") - - # Vergleiche mit bestehendem Event - current_start = existing_event.get('start', {}).get('dateTime', '') - current_end = existing_event.get('end', {}).get('dateTime', '') - current_summary = existing_event.get('summary', '') - current_description = existing_event.get('description', '') - current_location = existing_event.get('location', '') - - context.logger.debug(f"Vergleiche Start: {current_start} vs {start_datetime}") - context.logger.debug(f"Vergleiche End: {current_end} vs {end_datetime}") - context.logger.debug(f"Vergleiche Summary: {current_summary} vs {summary}") - context.logger.debug(f"Vergleiche Description: {current_description} vs {description}") - context.logger.debug(f"Vergleiche Location: {current_location} vs {location}") - - if (current_start == start_datetime and - current_end == end_datetime and - current_summary == summary and - current_description == description and - current_location == location): - context.logger.info(f"Termin {appointment.get('frNr')} unverändert, überspringe Google Update") - return None - - context.logger.info(f"Termin {appointment.get('frNr')} geändert, aktualisiere in Google...") - - event_body = { - 'summary': summary, - 'description': description, - 'location': location, - 'start': { - 'dateTime': start_datetime, - 'timeZone': 'Europe/Berlin', - }, - 'end': { - 'dateTime': end_datetime, - 'timeZone': 'Europe/Berlin', - } - } - - # Event aktualisieren - context.logger.info("Sende Google Calendar Update Request...") - updated_event = await google_api_call_with_backoff(service.events().update(calendarId=calendar_id, eventId=existing_event['id'], body=event_body).execute) - context.logger.info(f"Termin {appointment.get('frNr')} in Google Calendar aktualisiert") - return updated_event - + await advoware.api_call('api/v1/advonet/Termine', method='PUT', json_data=appointment_data) + logger.info(f"Updated Advoware appointment frNr: {frnr}") except Exception as e: - context.logger.error(f"Fehler beim Update des Google Events für Termin {appointment.get('frNr')}: {e}") + logger.error(f"Failed to update Advoware appointment {frnr}: {e}") + raise + +async def delete_advoware_appointment(advoware, frnr): + """Delete Advoware appointment.""" + try: + await advoware.api_call('api/v1/advonet/Termine', method='DELETE', params={'frnr': frnr}) + logger.info(f"Deleted Advoware appointment frNr: {frnr}") + except Exception as e: + logger.error(f"Failed to delete Advoware appointment {frnr}: {e}") + raise + +async def create_google_event(service, calendar_id, data): + """Create Google event from standardized data.""" + start_dt = data['start'].astimezone(BERLIN_TZ) + end_dt = data['end'].astimezone(BERLIN_TZ) + all_day = data['dauertermin'] == 1 and start_dt.time() == datetime.time(0,0) and end_dt.time() == datetime.time(0,0) + if all_day: + start_obj = {'date': start_dt.strftime('%Y-%m-%d')} + end_obj = {'date': end_dt.strftime('%Y-%m-%d')} + else: + start_obj = {'dateTime': start_dt.isoformat(), 'timeZone': 'Europe/Berlin'} + end_obj = {'dateTime': end_dt.isoformat(), 'timeZone': 'Europe/Berlin'} + event_body = { + 'summary': data['text'], + 'description': data['notiz'], + 'location': data['ort'], + 'start': start_obj, + 'end': end_obj, + 'recurrence': data['recurrence'] # RRULE if present + } + try: + created = service.events().insert(calendarId=calendar_id, body=event_body).execute() + event_id = created['id'] + logger.info(f"Created Google event ID: {event_id}") + return event_id + except HttpError as e: + logger.error(f"Google API error creating event: {e}") + raise + except Exception as e: + logger.error(f"Failed to create Google event: {e}") + raise + +async def update_google_event(service, calendar_id, event_id, data): + """Update Google event.""" + start_dt = data['start'].astimezone(BERLIN_TZ) + end_dt = data['end'].astimezone(BERLIN_TZ) + all_day = data['dauertermin'] == 1 and start_dt.time() == datetime.time(0,0) and end_dt.time() == datetime.time(0,0) + if all_day: + start_obj = {'date': start_dt.strftime('%Y-%m-%d')} + end_obj = {'date': end_dt.strftime('%Y-%m-%d')} + else: + start_obj = {'dateTime': start_dt.isoformat(), 'timeZone': 'Europe/Berlin'} + end_obj = {'dateTime': end_dt.isoformat(), 'timeZone': 'Europe/Berlin'} + event_body = { + 'summary': data['text'], + 'description': data['notiz'], + 'location': data['ort'], + 'start': start_obj, + 'end': end_obj, + 'recurrence': data['recurrence'] + } + try: + service.events().update(calendarId=calendar_id, eventId=event_id, body=event_body).execute() + logger.info(f"Updated Google event ID: {event_id}") + except HttpError as e: + logger.error(f"Google API error updating event {event_id}: {e}") + raise + except Exception as e: + logger.error(f"Failed to update Google event {event_id}: {e}") + raise + +async def delete_google_event(service, calendar_id, event_id): + """Delete Google event.""" + try: + service.events().delete(calendarId=calendar_id, eventId=event_id).execute() + logger.info(f"Deleted Google event ID: {event_id}") + except HttpError as e: + logger.error(f"Google API error deleting event {event_id}: {e}") + raise + except Exception as e: + logger.error(f"Failed to delete Google event {event_id}: {e}") + raise + +async def get_advoware_timestamp(advoware, frnr): + """Fetch last modified timestamp from Advoware (zuletztGeaendertAm).""" + try: + result = await advoware.api_call('api/v1/advonet/Termine', method='GET', params={'frnr': frnr}) + if result and isinstance(result, list) and result: + return datetime.datetime.fromisoformat(result[0].get('zuletztGeaendertAm', '').rstrip('Z')).astimezone(BERLIN_TZ) + return None + except Exception as e: + logger.error(f"Failed to get Advoware timestamp for {frnr}: {e}") return None - -async def update_advoware_appointment(advoware, frnr, change_token, data, context): - """Aktualisiert einen Advoware-Termin mit Token-Validierung""" - context.logger.info(f"Aktualisiere Advoware Termin {frnr}...") - expected_token = generate_change_token(frnr) - if change_token != expected_token: - context.logger.error(f"Token-Validierung fehlgeschlagen für Update von Termin {frnr}: expected {expected_token}, got {change_token}") - raise ValueError("Invalid change token") - - # Entferne frNr aus data, da es in der URL ist - data_copy = data.copy() - context.logger.debug(f"Sende PUT Request mit Daten: {data_copy}") - result = await advoware.api_call('api/v1/advonet/Termine', method='PUT', json_data=data_copy) - context.logger.info(f"Advoware Termin {frnr} aktualisiert") - return result - -async def delete_advoware_appointment(advoware, frnr, change_token, context): - """Löscht einen Advoware-Termin mit Token-Validierung""" - context.logger.info(f"Lösche Advoware Termin {frnr}...") - expected_token = generate_change_token(frnr) - if change_token != expected_token: - context.logger.error(f"Token-Validierung fehlgeschlagen für Löschung von Termin {frnr}: expected {expected_token}, got {change_token}") - raise ValueError("Invalid change token") - - context.logger.debug("Sende DELETE Request...") - result = await advoware.api_call('api/v1/advonet/Termine', method='DELETE', params={'frnr': frnr}) - context.logger.info(f"Advoware Termin {frnr} gelöscht") - return result async def handler(event, context): + """Main event handler for calendar sync.""" + employee_kuerzel = event.get('data', {}).get('body', {}).get('employee_kuerzel', 'AI') # Default to 'AI' for test + logger.info(f"Starting calendar sync for {employee_kuerzel}") + try: - # Konfiguration aus Event-Daten - data = event.get('data', {}) - body = data.get('body', {}) - full_content = body.get('full_content', True) # Default: volle Termindetails + service = await get_google_service() + calendar_id = await ensure_google_calendar(service, employee_kuerzel) + advoware = AdvowareAPI(context) - context.logger.info(f"=== Calendar Sync Event gestartet ===") - context.logger.info(f"Event Data: {data}") - context.logger.info(f"Full Content: {full_content}, Triggered By: {body.get('triggered_by')}") + async with await connect_db() as conn: + async with conn.transaction(): + # Lock rows + rows = await conn.fetch( + """ + SELECT * FROM calendar_sync + WHERE employee_kuerzel = $1 AND deleted = FALSE + FOR UPDATE + """, + employee_kuerzel + ) - # Google Calendar Service initialisieren - context.logger.info("Initialisiere Google Calendar Service...") - service = await get_google_service(context) - if not service: - context.logger.warn("Google Calendar Service nicht verfügbar. Sync wird übersprungen.") - return { - 'status': 200, - 'body': { - 'status': 'skipped', - 'reason': 'Google Calendar credentials not configured', - 'total_synced': 0 - } - } + # Build maps + adv_appointments = await fetch_advoware_appointments(advoware, employee_kuerzel) + adv_map = {str(app['frNr']): app for app in adv_appointments if app.get('frNr')} + google_events = await fetch_google_events(service, calendar_id) + google_map = {evt['id']: evt for evt in google_events} - # Alle Mitarbeiter abrufen - context.logger.info("Rufe Advoware Mitarbeiter ab...") - employees = await get_advoware_employees(context) + adv_map_std = {frnr: standardize_appointment_data(app, 'advoware') for frnr, app in adv_map.items()} + google_map_std = {eid: standardize_appointment_data(evt, 'google') for eid, evt in google_map.items()} - if not employees: - context.logger.error("Keine Mitarbeiter gefunden. Sync abgebrochen.") - return {'status': 500, 'body': {'error': 'Keine Mitarbeiter gefunden'}} + # Build index from DB rows + db_adv_index = {row['advoware_frnr']: row for row in rows if row['advoware_frnr']} + db_google_index = {row['google_event_id']: row for row in rows if row['google_event_id']} - total_synced = 0 - deleted_frnrs = set() # Sammle frNr von gelöschten Terminen + # Process existing + for row in rows: + frnr = row['advoware_frnr'] + event_id = row['google_event_id'] + adv_data = adv_map.pop(frnr, None) if frnr else None + google_data = google_map.pop(event_id, None) if event_id else None + adv_std = adv_map_std.pop(frnr, None) if frnr else None + google_std = google_map_std.pop(event_id, None) if event_id else None - for employee in employees: - kuerzel = employee.get('kuerzel') or employee.get('anwalt') - if not kuerzel: - context.logger.warn(f"Mitarbeiter ohne Kürzel übersprungen: {employee}") - continue + if not adv_data and not google_data: + # Both missing - soft delete + await conn.execute("UPDATE calendar_sync SET deleted = TRUE WHERE sync_id = $1;", row['sync_id']) + logger.info(f"Marked as deleted: sync_id {row['sync_id']}") + continue - # Zum Testen nur "AI" synchronisieren - if kuerzel != 'AI': - context.logger.info(f"Mitarbeiter {kuerzel} übersprungen (nur AI im Testmodus)") - continue + if adv_data and google_data: + # Both exist - check diff + if data_diff(adv_std, google_std): + strategy = row['sync_strategy'] + if strategy == 'source_system_wins': + if row['source_system'] == 'advoware': + await update_google_event(service, calendar_id, event_id, adv_std) + elif row['source_system'] == 'google' and row['advoware_write_allowed']: + await update_advoware_appointment(advoware, frnr, google_std) + else: + logger.warning(f"Write to Advoware blocked for sync_id {row['sync_id']}") + elif strategy == 'last_change_wins': + adv_ts = await get_advoware_timestamp(advoware, frnr) + google_ts = datetime.datetime.fromisoformat(google_data.get('updated', '').rstrip('Z')).astimezone(BERLIN_TZ) + if adv_ts and google_ts: + if adv_ts > google_ts: + await update_google_event(service, calendar_id, event_id, adv_std) + elif row['advoware_write_allowed']: + await update_advoware_appointment(advoware, frnr, google_std) + else: + logger.warning(f"Missing timestamps for last_change_wins: sync_id {row['sync_id']}") - context.logger.info(f"=== Verarbeite Mitarbeiter: {kuerzel} ===") - - # Google Calendar sicherstellen - calendar_id = await ensure_google_calendar(service, kuerzel, context) - if not calendar_id: - context.logger.error(f"Google Calendar für {kuerzel} konnte nicht erstellt werden. Überspringe.") - continue - - # Termine aus beiden Systemen abrufen - context.logger.info(f"Rufe Advoware Termine für {kuerzel} ab...") - advoware_appointments = await get_advoware_appointments(kuerzel, context) - context.logger.info(f"Rufe Google Events für {kuerzel} ab...") - google_events = await get_google_events(service, calendar_id, context) - - # Advoware → Google syncen - context.logger.info("=== Advoware → Google Sync starten ===") - frnr_to_event = {} - for event in google_events: - desc = event.get('description', '') - if 'frNr: ' in desc: - parts = desc.split('frNr: ') - if len(parts) > 1: - frnr = parts[1].split('\n')[0] - frnr_to_event[frnr] = event - context.logger.debug(f"Google Event {event.get('id')} zu frNr {frnr} zugeordnet") - - for appointment in advoware_appointments: - frnr = str(appointment.get('frNr')) - if frnr in deleted_frnrs: - context.logger.info(f"Termin {frnr} wurde gerade gelöscht, überspringe Google Sync") - continue - if frnr in frnr_to_event: - # Termin existiert bereits, aktualisieren - context.logger.info(f"Termin {frnr} existiert in Google, aktualisiere...") - existing_event = frnr_to_event[frnr] - await update_google_event(service, calendar_id, existing_event, appointment, context) - total_synced += 1 - else: - # Neuen Termin erstellen - context.logger.info(f"Termin {frnr} neu in Advoware, erstelle in Google...") - await sync_appointment_to_google(service, calendar_id, appointment, full_content, context) - total_synced += 1 - - # Google → Advoware syncen - context.logger.info("=== Google → Advoware Sync starten ===") - for event in google_events: - context.logger.info(f"Verarbeite Google Event {event.get('id')}: {event.get('summary')}") - await sync_event_to_advoware(service, calendar_id, event, kuerzel, context) - - # Löschungen handhaben: Google-initiale Termine, die in Google gelöscht wurden - context.logger.info("=== Löschungen behandeln ===") - google_frnrs_with_token = set() - for event in google_events: - desc = event.get('description', '') - if 'frNr: ' in desc and 'sync-token: ' in desc: - frnr_parts = desc.split('frNr: ') - if len(frnr_parts) > 1: - frnr = frnr_parts[1].split('\n')[0] - google_frnrs_with_token.add(frnr) - context.logger.debug(f"Google Event mit Token gefunden: frNr {frnr}") - - for appointment in advoware_appointments: - notiz = appointment.get('notiz', '') - if '## no change below this line ##' in notiz and 'sync-token: ' in notiz: - frnr = str(appointment.get('frNr')) - # Token aus notiz extrahieren - parts = notiz.split('sync-token: ') - if len(parts) > 1: - stored_token = parts[1].split('\n')[0] - expected_token = generate_change_token(frnr) - context.logger.debug(f"Prüfe Löschung für Termin {frnr}: stored_token={stored_token}, expected={expected_token}, in_google={frnr in google_frnrs_with_token}") - if stored_token == expected_token and frnr not in google_frnrs_with_token: - # Lösche in Advoware - context.logger.info(f"Termin {frnr} in Google gelöscht, lösche in Advoware...") - advoware = AdvowareAPI(context) - await delete_advoware_appointment(advoware, frnr, stored_token, context) - deleted_frnrs.add(frnr) + elif adv_data: + # Missing in Google - recreate or delete? + strategy = row['sync_strategy'] + if strategy == 'source_system_wins' and row['source_system'] == 'advoware': + new_event_id = await create_google_event(service, calendar_id, adv_std) + await conn.execute( + "UPDATE calendar_sync SET google_event_id = $1 WHERE sync_id = $2;", + new_event_id, row['sync_id'] + ) + elif strategy == 'last_change_wins': + # Assume Adv change newer - recreate + new_event_id = await create_google_event(service, calendar_id, adv_std) + await conn.execute( + "UPDATE calendar_sync SET google_event_id = $1 WHERE sync_id = $2;", + new_event_id, row['sync_id'] + ) else: - context.logger.debug(f"Termin {frnr} nicht gelöscht: Token mismatch oder noch in Google") - else: - context.logger.warn(f"Ungültiger sync-token in Termin {frnr}") - else: - context.logger.debug(f"Termin {frnr} hat keinen Token, überspringe Löschprüfung") + # Propagate delete to Advoware if allowed + if row['advoware_write_allowed']: + await delete_advoware_appointment(advoware, frnr) + await conn.execute("UPDATE calendar_sync SET deleted = TRUE WHERE sync_id = $1;", row['sync_id']) - context.logger.info(f"=== Advoware Calendar Sync abgeschlossen. {total_synced} Termine synchronisiert. Gelöschte Termine: {len(deleted_frnrs)} ===") + elif google_data: + # Missing in Advoware - recreate or delete? + strategy = row['sync_strategy'] + if strategy == 'source_system_wins' and row['source_system'] == 'google' and row['advoware_write_allowed']: + new_frnr = await create_advoware_appointment(advoware, google_std, employee_kuerzel) + await conn.execute( + "UPDATE calendar_sync SET advoware_frnr = $1 WHERE sync_id = $2;", + int(new_frnr), row['sync_id'] + ) + elif strategy == 'last_change_wins' and row['advoware_write_allowed']: + # Assume Google change newer - recreate + new_frnr = await create_advoware_appointment(advoware, google_std, employee_kuerzel) + await conn.execute( + "UPDATE calendar_sync SET advoware_frnr = $1 WHERE sync_id = $2;", + int(new_frnr), row['sync_id'] + ) + else: + # Propagate delete to Google + await delete_google_event(service, calendar_id, event_id) + await conn.execute("UPDATE calendar_sync SET deleted = TRUE WHERE sync_id = $1;", row['sync_id']) - return { - 'status': 200, - 'body': { - 'status': 'completed', - 'total_synced': total_synced, - 'employees_processed': len([e for e in employees if e.get('kuerzel') or e.get('anwalt')]), - 'deleted_appointments': len(deleted_frnrs) - } - } + # New from Advoware + for frnr, app in adv_map.items(): + adv_std = standardize_appointment_data(app, 'advoware') + event_id = await create_google_event(service, calendar_id, adv_std) + await conn.execute( + """ + INSERT INTO calendar_sync (employee_kuerzel, advoware_frnr, google_event_id, source_system, sync_strategy, advoware_write_allowed) + VALUES ($1, $2, $3, 'advoware', 'source_system_wins', FALSE); + """, + employee_kuerzel, int(frnr), event_id + ) + logger.info(f"Created new from Advoware: frNr {frnr}, event_id {event_id}") + + # New from Google + for event_id, evt in google_map.items(): + google_std = standardize_appointment_data(evt, 'google') + frnr = await create_advoware_appointment(advoware, google_std, employee_kuerzel) + await conn.execute( + """ + INSERT INTO calendar_sync (employee_kuerzel, advoware_frnr, google_event_id, source_system, sync_strategy, advoware_write_allowed) + VALUES ($1, $2, $3, 'google', 'source_system_wins', TRUE); + """, + employee_kuerzel, int(frnr), event_id + ) + logger.info(f"Created new from Google: event_id {event_id}, frNr {frnr}") + + # Update last_sync + await conn.execute( + "UPDATE calendar_sync SET last_sync = NOW() WHERE employee_kuerzel = $1;", + employee_kuerzel + ) + + logger.info(f"Calendar sync completed for {employee_kuerzel}") + return {'status': 200, 'body': {'status': 'completed'}} except Exception as e: - context.logger.error(f"Fehler beim Advoware Calendar Sync: {e}") - return { - 'status': 500, - 'body': { - 'error': 'Internal server error', - 'details': str(e) - } - } \ No newline at end of file + logger.error(f"Sync failed for {employee_kuerzel}: {e}") + return {'status': 500, 'body': {'error': str(e)}} \ No newline at end of file diff --git a/bitbylaw/steps/advoware_proxy/advoware_api_proxy_delete_step.py b/bitbylaw/steps/advoware_proxy/advoware_api_proxy_delete_step.py index 35fc2e1f..8cc1d9b5 100644 --- a/bitbylaw/steps/advoware_proxy/advoware_api_proxy_delete_step.py +++ b/bitbylaw/steps/advoware_proxy/advoware_api_proxy_delete_step.py @@ -7,7 +7,7 @@ config = { 'path': '/advoware/proxy', 'method': 'DELETE', 'emits': [], - 'flows': ['basic-tutorial', 'advoware'] + 'flows': ['advoware'] } async def handler(req, context): @@ -23,10 +23,7 @@ async def handler(req, context): json_data = None context.logger.info(f"Proxying request to Advoware: {method} {endpoint}") - context.logger.info(f"Query params: {params}") result = await advoware.api_call(endpoint, method=method, params=params, json_data=json_data) - context.logger.info(f"Advoware API response received, length: {len(str(result)) if result else 0}") - context.logger.info(f"Response preview: {str(result)[:500] if result else 'None'}") return {'status': 200, 'body': {'result': result}} except Exception as e: diff --git a/bitbylaw/steps/advoware_proxy/advoware_api_proxy_get_step.py b/bitbylaw/steps/advoware_proxy/advoware_api_proxy_get_step.py index 1b4ca79f..2463f4c8 100644 --- a/bitbylaw/steps/advoware_proxy/advoware_api_proxy_get_step.py +++ b/bitbylaw/steps/advoware_proxy/advoware_api_proxy_get_step.py @@ -7,7 +7,7 @@ config = { 'path': '/advoware/proxy', 'method': 'GET', 'emits': [], - 'flows': ['basic-tutorial', 'advoware'] + 'flows': ['advoware'] } async def handler(req, context): @@ -23,10 +23,7 @@ async def handler(req, context): json_data = None context.logger.info(f"Proxying request to Advoware: {method} {endpoint}") - context.logger.info(f"Query params: {params}") result = await advoware.api_call(endpoint, method=method, params=params, json_data=json_data) - context.logger.info(f"Advoware API response received, length: {len(str(result)) if result else 0}") - context.logger.info(f"Response preview: {str(result)[:500] if result else 'None'}") return {'status': 200, 'body': {'result': result}} except Exception as e: diff --git a/bitbylaw/steps/advoware_proxy/advoware_api_proxy_put_step.py b/bitbylaw/steps/advoware_proxy/advoware_api_proxy_put_step.py index f13b2d65..c7b6447b 100644 --- a/bitbylaw/steps/advoware_proxy/advoware_api_proxy_put_step.py +++ b/bitbylaw/steps/advoware_proxy/advoware_api_proxy_put_step.py @@ -7,7 +7,7 @@ config = { 'path': '/advoware/proxy', 'method': 'PUT', 'emits': [], - 'flows': ['basic-tutorial', 'advoware'] + 'flows': ['advoware'] } async def handler(req, context): @@ -23,11 +23,7 @@ async def handler(req, context): json_data = req.get('body') context.logger.info(f"Proxying request to Advoware: {method} {endpoint}") - context.logger.info(f"Query params: {params}") - context.logger.info(f"Request body: {json_data}") result = await advoware.api_call(endpoint, method=method, params=params, json_data=json_data) - context.logger.info(f"Advoware API response received, length: {len(str(result)) if result else 0}") - context.logger.info(f"Response preview: {str(result)[:500] if result else 'None'}") return {'status': 200, 'body': {'result': result}} except Exception as e: diff --git a/bitbylaw/types.d.ts b/bitbylaw/types.d.ts index 78a0fc70..db56787a 100644 --- a/bitbylaw/types.d.ts +++ b/bitbylaw/types.d.ts @@ -24,6 +24,8 @@ declare module 'motia' { 'Advoware Proxy POST': ApiRouteHandler, unknown, never> 'Advoware Proxy GET': ApiRouteHandler, unknown, never> 'Advoware Proxy DELETE': ApiRouteHandler, unknown, never> - 'Advoware Calendar Sync': ApiRouteHandler, unknown, never> + 'Calendar Sync Event Handler': EventHandler + 'Calendar Sync Cron Job': CronHandler<{ topic: 'calendar.sync.triggered'; data: never }> + 'Calendar Sync API Trigger': ApiRouteHandler, unknown, { topic: 'calendar.sync.triggered'; data: never }> } } \ No newline at end of file