Add calendar sync utilities and Beteiligte sync cron job

- Implemented calendar_sync_utils.py for shared utility functions
  including DB connection, Google Calendar service initialization,
  Redis client setup, and employee sync operations.

- Created beteiligte_sync_cron_step.py to handle periodic sync
  of Beteiligte entities, checking for new, modified, failed,
  and stale records, and emitting sync events accordingly.
This commit is contained in:
bsiggel
2026-03-01 22:55:17 +00:00
parent 17f908d036
commit 52356e634e
17 changed files with 2104 additions and 462 deletions

View File

@@ -0,0 +1,5 @@
"""
Advoware Calendar Sync Module
Bidirectional synchronization between Google Calendar and Advoware appointments.
"""

View File

@@ -0,0 +1,113 @@
"""
Calendar Sync All Step
Handles calendar_sync_all event and emits individual sync events for oldest employees.
Uses Redis to track last sync times and distribute work.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from calendar_sync_utils import (
get_redis_client,
get_advoware_employees,
set_employee_lock,
log_operation
)
import math
import time
from datetime import datetime
from typing import Any
from motia import queue, FlowContext
from pydantic import BaseModel, Field
from services.advoware_service import AdvowareService
config = {
'name': 'Calendar Sync All Step',
'description': 'Receives sync-all event and emits individual events for oldest employees',
'flows': ['advoware'],
'triggers': [
queue('calendar_sync_all')
],
'enqueues': ['calendar_sync_employee']
}
async def handler(input_data: dict, ctx: FlowContext):
"""
Handler that fetches all employees, sorts by last sync time,
and emits calendar_sync_employee events for the oldest ones.
"""
try:
triggered_by = input_data.get('triggered_by', 'unknown')
log_operation('info', f"Calendar Sync All: Starting to emit events for oldest employees, triggered by {triggered_by}", context=ctx)
# Initialize Advoware service
advoware = AdvowareService(ctx)
# Fetch employees
employees = await get_advoware_employees(advoware, ctx)
if not employees:
log_operation('error', "Keine Mitarbeiter gefunden. All-Sync abgebrochen.", context=ctx)
return {'status': 500, 'body': {'error': 'Keine Mitarbeiter gefunden'}}
redis_client = get_redis_client(ctx)
# Collect last_synced timestamps
employee_timestamps = {}
for employee in employees:
kuerzel = employee.get('kuerzel')
if not kuerzel:
continue
employee_last_synced_key = f'calendar_sync_last_synced_{kuerzel}'
timestamp_str = redis_client.get(employee_last_synced_key)
timestamp = int(timestamp_str) if timestamp_str else 0 # 0 if no timestamp (very old)
employee_timestamps[kuerzel] = timestamp
# Sort employees by last_synced (ascending, oldest first), then by kuerzel alphabetically
sorted_kuerzel = sorted(employee_timestamps.keys(), key=lambda k: (employee_timestamps[k], k))
# Log the sorted list with timestamps
def format_timestamp(ts):
if ts == 0:
return "never"
return datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
sorted_list_str = ", ".join(f"{k} ({format_timestamp(employee_timestamps[k])})" for k in sorted_kuerzel)
log_operation('info', f"Calendar Sync All: Sorted employees by last synced: {sorted_list_str}", context=ctx)
# Calculate number to sync: ceil(N / 10)
num_to_sync = math.ceil(len(sorted_kuerzel) / 1)
log_operation('info', f"Calendar Sync All: Total employees {len(sorted_kuerzel)}, syncing {num_to_sync} oldest", context=ctx)
# Emit for the oldest num_to_sync employees, if not locked
emitted_count = 0
for kuerzel in sorted_kuerzel[:num_to_sync]:
if not set_employee_lock(redis_client, kuerzel, triggered_by, ctx):
log_operation('info', f"Calendar Sync All: Sync already active for {kuerzel}, skipping", context=ctx)
continue
# Emit event for this employee
await ctx.enqueue({
"topic": "calendar_sync_employee",
"data": {
"kuerzel": kuerzel,
"triggered_by": triggered_by
}
})
log_operation('info', f"Calendar Sync All: Emitted event for employee {kuerzel} (last synced: {format_timestamp(employee_timestamps[kuerzel])})", context=ctx)
emitted_count += 1
log_operation('info', f"Calendar Sync All: Completed, emitted {emitted_count} events", context=ctx)
return {
'status': 'completed',
'triggered_by': triggered_by,
'emitted_count': emitted_count
}
except Exception as e:
log_operation('error', f"Fehler beim All-Sync: {e}", context=ctx)
return {
'status': 'error',
'error': str(e)
}

View File

@@ -0,0 +1,112 @@
"""
Calendar Sync API Step
HTTP API endpoint for manual calendar sync triggering.
Supports syncing a single employee or all employees.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from calendar_sync_utils import get_redis_client, set_employee_lock, log_operation
from motia import http, ApiRequest, ApiResponse, FlowContext
config = {
'name': 'Calendar Sync API Trigger',
'description': 'API endpoint for manual calendar sync triggering',
'flows': ['advoware'],
'triggers': [
http('POST', '/advoware/calendar/sync')
],
'enqueues': ['calendar_sync_employee', 'calendar_sync_all']
}
async def handler(request: ApiRequest, ctx: FlowContext) -> ApiResponse:
"""
HTTP handler for manual calendar sync triggering.
Request body:
{
"kuerzel": "SB" // or "ALL" for all employees
}
"""
try:
# Get kuerzel from request body
body = request.body
kuerzel = body.get('kuerzel')
if not kuerzel:
return ApiResponse(
status=400,
body={
'error': 'kuerzel required',
'message': 'Bitte kuerzel im Body angeben'
}
)
kuerzel_upper = kuerzel.upper()
if kuerzel_upper == 'ALL':
# Emit sync-all event
log_operation('info', "Calendar Sync API: Emitting sync-all event", context=ctx)
await ctx.enqueue({
"topic": "calendar_sync_all",
"data": {
"triggered_by": "api"
}
})
return ApiResponse(
status=200,
body={
'status': 'triggered',
'message': 'Calendar sync wurde für alle Mitarbeiter ausgelöst',
'triggered_by': 'api'
}
)
else:
# Single employee sync
redis_client = get_redis_client(ctx)
if not set_employee_lock(redis_client, kuerzel_upper, 'api', ctx):
log_operation('info', f"Calendar Sync API: Sync already active for {kuerzel_upper}, skipping", context=ctx)
return ApiResponse(
status=409,
body={
'status': 'conflict',
'message': f'Calendar sync already active for {kuerzel_upper}',
'kuerzel': kuerzel_upper,
'triggered_by': 'api'
}
)
log_operation('info', f"Calendar Sync API called for {kuerzel_upper}", context=ctx)
# Lock successfully set, now emit event
await ctx.enqueue({
"topic": "calendar_sync_employee",
"data": {
"kuerzel": kuerzel_upper,
"triggered_by": "api"
}
})
return ApiResponse(
status=200,
body={
'status': 'triggered',
'message': f'Calendar sync was triggered for {kuerzel_upper}',
'kuerzel': kuerzel_upper,
'triggered_by': 'api'
}
)
except Exception as e:
log_operation('error', f"Error in API trigger: {e}", context=ctx)
return ApiResponse(
status=500,
body={
'error': 'Internal server error',
'details': str(e)
}
)

View File

@@ -0,0 +1,50 @@
"""
Calendar Sync Cron Step
Cron trigger for automatic calendar synchronization.
Emits calendar_sync_all event to start sync cascade.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from calendar_sync_utils import log_operation
from motia import cron, FlowContext
config = {
'name': 'Calendar Sync Cron Job',
'description': 'Runs calendar sync automatically every 15 minutes',
'flows': ['advoware'],
'triggers': [
cron("0 */15 * * * *") # Every 15 minutes (6-field: sec min hour day month weekday)
],
'enqueues': ['calendar_sync_all']
}
async def handler(input_data: dict, ctx: FlowContext):
"""Cron handler that triggers the calendar sync cascade."""
try:
log_operation('info', "Calendar Sync Cron: Starting to emit sync-all event", context=ctx)
# Enqueue sync-all event
await ctx.enqueue({
"topic": "calendar_sync_all",
"data": {
"triggered_by": "cron"
}
})
log_operation('info', "Calendar Sync Cron: Emitted sync-all event", context=ctx)
return {
'status': 'completed',
'triggered_by': 'cron'
}
except Exception as e:
log_operation('error', f"Fehler beim Cron-Job: {e}", context=ctx)
return {
'status': 'error',
'error': str(e)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
"""
Calendar Sync Utilities
Shared utility functions for calendar synchronization between Google Calendar and Advoware.
"""
import logging
import asyncpg
import os
import redis
import time
from googleapiclient.discovery import build
from google.oauth2 import service_account
# Configure logging
logger = logging.getLogger(__name__)
def log_operation(level: str, message: str, context=None, **context_vars):
"""Centralized logging with context, supporting file and console logging."""
context_str = ' '.join(f"{k}={v}" for k, v in context_vars.items() if v is not None)
full_message = f"[{time.time()}] {message} {context_str}".strip()
# Log via logger
if level == 'info':
logger.info(full_message)
elif level == 'warning':
logger.warning(full_message)
elif level == 'error':
logger.error(full_message)
elif level == 'debug':
logger.debug(full_message)
# Also log to console for journalctl visibility
print(f"[{level.upper()}] {full_message}")
async def connect_db(context=None):
"""Connect to Postgres DB from environment variables."""
try:
conn = await asyncpg.connect(
host=os.getenv('POSTGRES_HOST', 'localhost'),
user=os.getenv('POSTGRES_USER', 'calendar_sync_user'),
password=os.getenv('POSTGRES_PASSWORD', 'default_password'),
database=os.getenv('POSTGRES_DB_NAME', 'calendar_sync_db'),
timeout=10
)
return conn
except Exception as e:
log_operation('error', f"Failed to connect to DB: {e}", context=context)
raise
async def get_google_service(context=None):
"""Initialize Google Calendar service."""
try:
service_account_path = os.getenv('GOOGLE_CALENDAR_SERVICE_ACCOUNT_PATH', 'service-account.json')
if not os.path.exists(service_account_path):
raise FileNotFoundError(f"Service account file not found: {service_account_path}")
scopes = ['https://www.googleapis.com/auth/calendar']
creds = service_account.Credentials.from_service_account_file(
service_account_path, scopes=scopes
)
service = build('calendar', 'v3', credentials=creds)
return service
except Exception as e:
log_operation('error', f"Failed to initialize Google service: {e}", context=context)
raise
def get_redis_client(context=None):
"""Initialize Redis client for calendar sync operations."""
try:
redis_client = redis.Redis(
host=os.getenv('REDIS_HOST', 'localhost'),
port=int(os.getenv('REDIS_PORT', '6379')),
db=int(os.getenv('REDIS_DB_CALENDAR_SYNC', '2')),
socket_timeout=int(os.getenv('REDIS_TIMEOUT_SECONDS', '5'))
)
return redis_client
except Exception as e:
log_operation('error', f"Failed to initialize Redis client: {e}", context=context)
raise
async def get_advoware_employees(advoware, context=None):
"""Fetch list of employees from Advoware."""
try:
result = await advoware.api_call('api/v1/advonet/Mitarbeiter', method='GET', params={'aktiv': 'true'})
employees = result if isinstance(result, list) else []
log_operation('info', f"Fetched {len(employees)} Advoware employees", context=context)
return employees
except Exception as e:
log_operation('error', f"Failed to fetch Advoware employees: {e}", context=context)
raise
def set_employee_lock(redis_client, kuerzel: str, triggered_by: str, context=None) -> bool:
"""Set lock for employee sync operation."""
employee_lock_key = f'calendar_sync_lock_{kuerzel}'
if redis_client.set(employee_lock_key, triggered_by, ex=1800, nx=True) is None:
log_operation('info', f"Sync already active for {kuerzel}, skipping", context=context)
return False
return True
def clear_employee_lock(redis_client, kuerzel: str, context=None):
"""Clear lock for employee sync operation and update last-synced timestamp."""
try:
employee_lock_key = f'calendar_sync_lock_{kuerzel}'
employee_last_synced_key = f'calendar_sync_last_synced_{kuerzel}'
# Update last-synced timestamp (no TTL, persistent)
current_time = int(time.time())
redis_client.set(employee_last_synced_key, current_time)
# Delete the lock
redis_client.delete(employee_lock_key)
log_operation('debug', f"Cleared lock and updated last-synced for {kuerzel} to {current_time}", context=context)
except Exception as e:
log_operation('warning', f"Failed to clear lock and update last-synced for {kuerzel}: {e}", context=context)