Refactor and enhance logging in webhook handlers and Redis client
- Translated comments and docstrings from German to English for better clarity. - Improved logging consistency across various webhook handlers for create, delete, and update operations. - Centralized logging functionality by utilizing a dedicated logger utility. - Added new enums for file and XAI sync statuses in models. - Updated Redis client factory to use a centralized logger and improved error handling. - Enhanced API responses to include more descriptive messages and status codes.
This commit is contained in:
@@ -8,7 +8,6 @@ import hashlib
|
||||
import base64
|
||||
import os
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from services.exceptions import (
|
||||
@@ -21,8 +20,6 @@ from services.redis_client import get_redis_client
|
||||
from services.config import ADVOWARE_CONFIG, API_CONFIG
|
||||
from services.logging_utils import get_service_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdvowareAPI:
|
||||
"""
|
||||
@@ -93,7 +90,7 @@ class AdvowareAPI:
|
||||
|
||||
try:
|
||||
api_key_bytes = base64.b64decode(self.api_key)
|
||||
logger.debug("API Key decoded from base64")
|
||||
self.logger.debug("API Key decoded from base64")
|
||||
except Exception as e:
|
||||
self._log(f"API Key not base64-encoded, using as-is: {e}", level='debug')
|
||||
api_key_bytes = self.api_key.encode('utf-8') if isinstance(self.api_key, str) else self.api_key
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
"""
|
||||
Advoware Service Wrapper
|
||||
Erweitert AdvowareAPI mit höheren Operations
|
||||
|
||||
Extends AdvowareAPI with higher-level operations for business logic.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from services.advoware import AdvowareAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from services.logging_utils import get_service_logger
|
||||
|
||||
|
||||
class AdvowareService:
|
||||
"""
|
||||
Service-Layer für Advoware Operations
|
||||
Verwendet AdvowareAPI für API-Calls
|
||||
Service layer for Advoware operations.
|
||||
Uses AdvowareAPI for API calls.
|
||||
"""
|
||||
|
||||
def __init__(self, context=None):
|
||||
self.api = AdvowareAPI(context)
|
||||
self.context = context
|
||||
self.logger = get_service_logger('advoware_service', context)
|
||||
|
||||
def _log(self, message: str, level: str = 'info') -> None:
|
||||
"""Internal logging helper"""
|
||||
log_func = getattr(self.logger, level, self.logger.info)
|
||||
log_func(message)
|
||||
|
||||
async def api_call(self, *args, **kwargs):
|
||||
"""Delegate api_call to underlying AdvowareAPI"""
|
||||
@@ -26,29 +31,29 @@ class AdvowareService:
|
||||
|
||||
# ========== BETEILIGTE ==========
|
||||
|
||||
async def get_beteiligter(self, betnr: int) -> Optional[Dict]:
|
||||
async def get_beteiligter(self, betnr: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Lädt Beteiligten mit allen Daten
|
||||
Load Beteiligte with all data.
|
||||
|
||||
Returns:
|
||||
Beteiligte-Objekt
|
||||
Beteiligte object or None
|
||||
"""
|
||||
try:
|
||||
endpoint = f"api/v1/advonet/Beteiligte/{betnr}"
|
||||
result = await self.api.api_call(endpoint, method='GET')
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"[ADVO] Fehler beim Laden von Beteiligte {betnr}: {e}", exc_info=True)
|
||||
self._log(f"[ADVO] Error loading Beteiligte {betnr}: {e}", level='error')
|
||||
return None
|
||||
|
||||
# ========== KOMMUNIKATION ==========
|
||||
|
||||
async def create_kommunikation(self, betnr: int, data: Dict[str, Any]) -> Optional[Dict]:
|
||||
async def create_kommunikation(self, betnr: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Erstellt neue Kommunikation
|
||||
Create new Kommunikation.
|
||||
|
||||
Args:
|
||||
betnr: Beteiligten-Nummer
|
||||
betnr: Beteiligte number
|
||||
data: {
|
||||
'tlf': str, # Required
|
||||
'bemerkung': str, # Optional
|
||||
@@ -57,68 +62,68 @@ class AdvowareService:
|
||||
}
|
||||
|
||||
Returns:
|
||||
Neue Kommunikation mit 'id'
|
||||
New Kommunikation with 'id' or None
|
||||
"""
|
||||
try:
|
||||
endpoint = f"api/v1/advonet/Beteiligte/{betnr}/Kommunikationen"
|
||||
result = await self.api.api_call(endpoint, method='POST', json_data=data)
|
||||
|
||||
if result:
|
||||
logger.info(f"[ADVO] ✅ Created Kommunikation: betnr={betnr}, kommKz={data.get('kommKz')}")
|
||||
self._log(f"[ADVO] ✅ Created Kommunikation: betnr={betnr}, kommKz={data.get('kommKz')}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ADVO] Fehler beim Erstellen von Kommunikation: {e}", exc_info=True)
|
||||
self._log(f"[ADVO] Error creating Kommunikation: {e}", level='error')
|
||||
return None
|
||||
|
||||
async def update_kommunikation(self, betnr: int, komm_id: int, data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Aktualisiert bestehende Kommunikation
|
||||
Update existing Kommunikation.
|
||||
|
||||
Args:
|
||||
betnr: Beteiligten-Nummer
|
||||
komm_id: Kommunikation-ID
|
||||
betnr: Beteiligte number
|
||||
komm_id: Kommunikation ID
|
||||
data: {
|
||||
'tlf': str, # Optional
|
||||
'bemerkung': str, # Optional
|
||||
'online': bool # Optional
|
||||
}
|
||||
|
||||
NOTE: kommKz ist READ-ONLY und kann nicht geändert werden
|
||||
NOTE: kommKz is READ-ONLY and cannot be changed
|
||||
|
||||
Returns:
|
||||
True wenn erfolgreich
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
endpoint = f"api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{komm_id}"
|
||||
await self.api.api_call(endpoint, method='PUT', json_data=data)
|
||||
|
||||
logger.info(f"[ADVO] ✅ Updated Kommunikation: betnr={betnr}, komm_id={komm_id}")
|
||||
self._log(f"[ADVO] ✅ Updated Kommunikation: betnr={betnr}, komm_id={komm_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ADVO] Fehler beim Update von Kommunikation: {e}", exc_info=True)
|
||||
self._log(f"[ADVO] Error updating Kommunikation: {e}", level='error')
|
||||
return False
|
||||
|
||||
async def delete_kommunikation(self, betnr: int, komm_id: int) -> bool:
|
||||
"""
|
||||
Löscht Kommunikation (aktuell 403 Forbidden)
|
||||
Delete Kommunikation (currently returns 403 Forbidden).
|
||||
|
||||
NOTE: DELETE ist in Advoware API deaktiviert
|
||||
Verwende stattdessen: Leere Slots mit empty_slot_marker
|
||||
NOTE: DELETE is disabled in Advoware API.
|
||||
Use empty slots with empty_slot_marker instead.
|
||||
|
||||
Returns:
|
||||
True wenn erfolgreich
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
endpoint = f"api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{komm_id}"
|
||||
await self.api.api_call(endpoint, method='DELETE')
|
||||
|
||||
logger.info(f"[ADVO] ✅ Deleted Kommunikation: betnr={betnr}, komm_id={komm_id}")
|
||||
self._log(f"[ADVO] ✅ Deleted Kommunikation: betnr={betnr}, komm_id={komm_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Expected: 403 Forbidden
|
||||
logger.warning(f"[ADVO] DELETE not allowed (expected): {e}")
|
||||
self._log(f"[ADVO] DELETE not allowed (expected): {e}", level='warning')
|
||||
return False
|
||||
|
||||
@@ -207,16 +207,15 @@ class BeteiligteSync:
|
||||
except:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def parse_timestamp(ts: Any) -> Optional[datetime]:
|
||||
def parse_timestamp(self, ts: Any) -> Optional[datetime]:
|
||||
"""
|
||||
Parse verschiedene Timestamp-Formate zu datetime
|
||||
Parse various timestamp formats to datetime.
|
||||
|
||||
Args:
|
||||
ts: String, datetime oder None
|
||||
ts: String, datetime or None
|
||||
|
||||
Returns:
|
||||
datetime-Objekt oder None
|
||||
datetime object or None
|
||||
"""
|
||||
if not ts:
|
||||
return None
|
||||
@@ -225,13 +224,13 @@ class BeteiligteSync:
|
||||
return ts
|
||||
|
||||
if isinstance(ts, str):
|
||||
# EspoCRM Format: "2026-02-07 14:30:00"
|
||||
# Advoware Format: "2026-02-07T14:30:00" oder "2026-02-07T14:30:00Z"
|
||||
# EspoCRM format: "2026-02-07 14:30:00"
|
||||
# Advoware format: "2026-02-07T14:30:00" or "2026-02-07T14:30:00Z"
|
||||
try:
|
||||
# Entferne trailing Z falls vorhanden
|
||||
# Remove trailing Z if present
|
||||
ts = ts.rstrip('Z')
|
||||
|
||||
# Versuche verschiedene Formate
|
||||
# Try various formats
|
||||
for fmt in [
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M:%S',
|
||||
@@ -242,11 +241,11 @@ class BeteiligteSync:
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Fallback: ISO-Format
|
||||
# Fallback: ISO format
|
||||
return datetime.fromisoformat(ts)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Konnte Timestamp nicht parsen: {ts} - {e}")
|
||||
self._log(f"Could not parse timestamp: {ts} - {e}", level='warning')
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""
|
||||
Document Sync Utilities
|
||||
|
||||
Hilfsfunktionen für Document-Synchronisation mit xAI:
|
||||
Utility functions for document synchronization with xAI:
|
||||
- Distributed locking via Redis + syncStatus
|
||||
- Entscheidungslogik: Wann muss ein Document zu xAI?
|
||||
- Related Entities ermitteln (Many-to-Many Attachments)
|
||||
- xAI Collection Management
|
||||
- Decision logic: When does a document need xAI sync?
|
||||
- Related entities determination (Many-to-Many attachments)
|
||||
- xAI Collection management
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from services.sync_utils_base import BaseSyncUtils
|
||||
from services.models import FileStatus, XAISyncStatus
|
||||
|
||||
# Max retry before permanent failure
|
||||
MAX_SYNC_RETRIES = 5
|
||||
@@ -21,10 +22,10 @@ RETRY_BACKOFF_MINUTES = [1, 5, 15, 60, 240] # 1min, 5min, 15min, 1h, 4h
|
||||
|
||||
|
||||
class DocumentSync(BaseSyncUtils):
|
||||
"""Utility-Klasse für Document-Synchronisation mit xAI"""
|
||||
"""Utility class for document synchronization with xAI"""
|
||||
|
||||
def _get_lock_key(self, entity_id: str) -> str:
|
||||
"""Redis Lock-Key für Documents"""
|
||||
"""Redis lock key for documents"""
|
||||
return f"sync_lock:document:{entity_id}"
|
||||
|
||||
async def acquire_sync_lock(self, entity_id: str, entity_type: str = 'CDokumente') -> bool:
|
||||
@@ -45,13 +46,13 @@ class DocumentSync(BaseSyncUtils):
|
||||
self._log(f"Redis lock bereits aktiv für {entity_type} {entity_id}", level='warn')
|
||||
return False
|
||||
|
||||
# STEP 2: Update xaiSyncStatus auf pending_sync
|
||||
# STEP 2: Update xaiSyncStatus to pending_sync
|
||||
try:
|
||||
await self.espocrm.update_entity(entity_type, entity_id, {
|
||||
'xaiSyncStatus': 'pending_sync'
|
||||
'xaiSyncStatus': XAISyncStatus.PENDING_SYNC.value
|
||||
})
|
||||
except Exception as e:
|
||||
self._log(f"Konnte xaiSyncStatus nicht setzen: {e}", level='debug')
|
||||
self._log(f"Could not set xaiSyncStatus: {e}", level='debug')
|
||||
|
||||
self._log(f"Sync-Lock für {entity_type} {entity_id} erworben")
|
||||
return True
|
||||
@@ -84,16 +85,16 @@ class DocumentSync(BaseSyncUtils):
|
||||
try:
|
||||
update_data = {}
|
||||
|
||||
# xaiSyncStatus setzen: clean bei Erfolg, failed bei Fehler
|
||||
# Set xaiSyncStatus: clean on success, failed on error
|
||||
try:
|
||||
update_data['xaiSyncStatus'] = 'clean' if success else 'failed'
|
||||
update_data['xaiSyncStatus'] = XAISyncStatus.CLEAN.value if success else XAISyncStatus.FAILED.value
|
||||
|
||||
if error_message:
|
||||
update_data['xaiSyncError'] = error_message[:2000]
|
||||
else:
|
||||
update_data['xaiSyncError'] = None
|
||||
except:
|
||||
pass # Felder existieren evtl. nicht
|
||||
pass # Fields may not exist
|
||||
|
||||
# Merge extra fields (z.B. xaiFileId, xaiCollections)
|
||||
if extra_fields:
|
||||
@@ -120,37 +121,37 @@ class DocumentSync(BaseSyncUtils):
|
||||
entity_type: str = 'CDokumente'
|
||||
) -> Tuple[bool, List[str], str]:
|
||||
"""
|
||||
Entscheidet ob ein Document zu xAI synchronisiert werden muss
|
||||
Decide if a document needs to be synchronized to xAI.
|
||||
|
||||
Prüft:
|
||||
1. Datei-Status Feld ("Neu", "Geändert")
|
||||
2. Hash-Werte für Change Detection
|
||||
3. Related Entities mit xAI Collections
|
||||
Checks:
|
||||
1. File status field ("new", "changed")
|
||||
2. Hash values for change detection
|
||||
3. Related entities with xAI collections
|
||||
|
||||
Args:
|
||||
document: Vollständiges Document Entity von EspoCRM
|
||||
document: Complete document entity from EspoCRM
|
||||
|
||||
Returns:
|
||||
Tuple[bool, List[str], str]:
|
||||
- bool: Ob Sync nötig ist
|
||||
- List[str]: Liste der Collection-IDs in die das Document soll
|
||||
- str: Grund/Beschreibung der Entscheidung
|
||||
- bool: Whether sync is needed
|
||||
- List[str]: List of collection IDs where the document should go
|
||||
- str: Reason/description of the decision
|
||||
"""
|
||||
doc_id = document.get('id')
|
||||
doc_name = document.get('name', 'Unbenannt')
|
||||
|
||||
# xAI-relevante Felder
|
||||
# xAI-relevant fields
|
||||
xai_file_id = document.get('xaiFileId')
|
||||
xai_collections = document.get('xaiCollections') or []
|
||||
xai_sync_status = document.get('xaiSyncStatus')
|
||||
|
||||
# Datei-Status und Hash-Felder
|
||||
# File status and hash fields
|
||||
datei_status = document.get('dateiStatus') or document.get('fileStatus')
|
||||
file_md5 = document.get('md5') or document.get('fileMd5')
|
||||
file_sha = document.get('sha') or document.get('fileSha')
|
||||
xai_synced_hash = document.get('xaiSyncedHash') # Hash beim letzten xAI-Sync
|
||||
xai_synced_hash = document.get('xaiSyncedHash') # Hash at last xAI sync
|
||||
|
||||
self._log(f"📋 Document Analysis: {doc_name} (ID: {doc_id})")
|
||||
self._log(f"📋 Document analysis: {doc_name} (ID: {doc_id})")
|
||||
self._log(f" xaiFileId: {xai_file_id or 'N/A'}")
|
||||
self._log(f" xaiCollections: {xai_collections}")
|
||||
self._log(f" xaiSyncStatus: {xai_sync_status or 'N/A'}")
|
||||
@@ -165,65 +166,74 @@ class DocumentSync(BaseSyncUtils):
|
||||
entity_type=entity_type
|
||||
)
|
||||
|
||||
# Prüfe xaiSyncStatus="no_sync" → kein Sync für dieses Dokument
|
||||
if xai_sync_status == 'no_sync':
|
||||
self._log("⏭️ Kein xAI-Sync nötig: xaiSyncStatus='no_sync'")
|
||||
return (False, [], "xaiSyncStatus ist 'no_sync'")
|
||||
# Check xaiSyncStatus="no_sync" -> no sync for this document
|
||||
if xai_sync_status == XAISyncStatus.NO_SYNC.value:
|
||||
self._log("⏭️ No xAI sync needed: xaiSyncStatus='no_sync'")
|
||||
return (False, [], "xaiSyncStatus is 'no_sync'")
|
||||
|
||||
if not target_collections:
|
||||
self._log("⏭️ Kein xAI-Sync nötig: Keine Related Entities mit xAI Collections")
|
||||
return (False, [], "Keine verknüpften Entities mit xAI Collections")
|
||||
self._log("⏭️ No xAI sync needed: No related entities with xAI collections")
|
||||
return (False, [], "No linked entities with xAI collections")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# PRIORITY CHECK 1: xaiSyncStatus="unclean" → Dokument wurde geändert
|
||||
# PRIORITY CHECK 1: xaiSyncStatus="unclean" -> document was changed
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
if xai_sync_status == 'unclean':
|
||||
self._log(f"🆕 xaiSyncStatus='unclean' → xAI-Sync ERFORDERLICH")
|
||||
if xai_sync_status == XAISyncStatus.UNCLEAN.value:
|
||||
self._log(f"🆕 xaiSyncStatus='unclean' → xAI sync REQUIRED")
|
||||
return (True, target_collections, "xaiSyncStatus='unclean'")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# PRIORITY CHECK 2: fileStatus "new" oder "changed"
|
||||
# PRIORITY CHECK 2: fileStatus "new" or "changed"
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
if datei_status in ['new', 'changed', 'neu', 'geändert', 'New', 'Changed', 'Neu', 'Geändert']:
|
||||
self._log(f"🆕 fileStatus: '{datei_status}' → xAI-Sync ERFORDERLICH")
|
||||
if datei_status in [
|
||||
FileStatus.NEW.value,
|
||||
FileStatus.CHANGED.value,
|
||||
'neu', # Legacy German values
|
||||
'geändert', # Legacy German values
|
||||
'Neu', # Case variations
|
||||
'Geändert',
|
||||
'New',
|
||||
'Changed'
|
||||
]:
|
||||
self._log(f"🆕 fileStatus: '{datei_status}' → xAI sync REQUIRED")
|
||||
|
||||
if target_collections:
|
||||
return (True, target_collections, f"fileStatus: {datei_status}")
|
||||
else:
|
||||
# Datei ist neu/geändert aber keine Collections gefunden
|
||||
self._log(f"⚠️ fileStatus '{datei_status}' aber keine Collections gefunden - überspringe Sync")
|
||||
return (False, [], f"fileStatus: {datei_status}, aber keine Collections")
|
||||
# File is new/changed but no collections found
|
||||
self._log(f"⚠️ fileStatus '{datei_status}' but no collections found - skipping sync")
|
||||
return (False, [], f"fileStatus: {datei_status}, but no collections")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# FALL 1: Document ist bereits in xAI UND Collections sind gesetzt
|
||||
# CASE 1: Document is already in xAI AND collections are set
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
if xai_file_id:
|
||||
self._log(f"✅ Document bereits in xAI gesynct mit {len(target_collections)} Collection(s)")
|
||||
self._log(f"✅ Document already synced to xAI with {len(target_collections)} collection(s)")
|
||||
|
||||
# Prüfe ob File-Inhalt geändert wurde (Hash-Vergleich)
|
||||
# Check if file content was changed (hash comparison)
|
||||
current_hash = file_md5 or file_sha
|
||||
|
||||
if current_hash and xai_synced_hash:
|
||||
if current_hash != xai_synced_hash:
|
||||
self._log(f"🔄 Hash-Änderung erkannt! RESYNC erforderlich")
|
||||
self._log(f" Alt: {xai_synced_hash[:16]}...")
|
||||
self._log(f" Neu: {current_hash[:16]}...")
|
||||
return (True, target_collections, "File-Inhalt geändert (Hash-Mismatch)")
|
||||
self._log(f"🔄 Hash change detected! RESYNC required")
|
||||
self._log(f" Old: {xai_synced_hash[:16]}...")
|
||||
self._log(f" New: {current_hash[:16]}...")
|
||||
return (True, target_collections, "File content changed (hash mismatch)")
|
||||
else:
|
||||
self._log(f"✅ Hash identisch - keine Änderung")
|
||||
self._log(f"✅ Hash identical - no change")
|
||||
else:
|
||||
self._log(f"⚠️ Keine Hash-Werte verfügbar für Vergleich")
|
||||
self._log(f"⚠️ No hash values available for comparison")
|
||||
|
||||
return (False, target_collections, "Bereits gesynct, keine Änderung erkannt")
|
||||
return (False, target_collections, "Already synced, no change detected")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# FALL 2: Document hat xaiFileId aber Collections ist leer/None
|
||||
# CASE 2: Document has xaiFileId but collections is empty/None
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# FALL 3: Collections vorhanden aber kein Status/Hash-Trigger
|
||||
# CASE 3: Collections present but no status/hash trigger
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
self._log(f"✅ Document ist mit {len(target_collections)} Entity/ies verknüpft die Collections haben")
|
||||
return (True, target_collections, "Verknüpft mit Entities die Collections benötigen")
|
||||
self._log(f"✅ Document is linked to {len(target_collections)} entity/ies with collections")
|
||||
return (True, target_collections, "Linked to entities that require collections")
|
||||
|
||||
async def _get_required_collections_from_relations(
|
||||
self,
|
||||
@@ -231,20 +241,20 @@ class DocumentSync(BaseSyncUtils):
|
||||
entity_type: str = 'Document'
|
||||
) -> List[str]:
|
||||
"""
|
||||
Ermittelt alle xAI Collection-IDs von Entities die mit diesem Document verknüpft sind
|
||||
Determine all xAI collection IDs of entities linked to this document.
|
||||
|
||||
EspoCRM Many-to-Many: Document kann mit beliebigen Entities verknüpft sein
|
||||
EspoCRM Many-to-Many: Document can be linked to arbitrary entities
|
||||
(CBeteiligte, Account, CVmhErstgespraech, etc.)
|
||||
|
||||
Args:
|
||||
document_id: Document ID
|
||||
|
||||
Returns:
|
||||
Liste von xAI Collection-IDs (dedupliziert)
|
||||
List of xAI collection IDs (deduplicated)
|
||||
"""
|
||||
collections = set()
|
||||
|
||||
self._log(f"🔍 Prüfe Relations von {entity_type} {document_id}...")
|
||||
self._log(f"🔍 Checking relations of {entity_type} {document_id}...")
|
||||
|
||||
try:
|
||||
entity_def = await self.espocrm.get_entity_def(entity_type)
|
||||
|
||||
@@ -16,7 +16,7 @@ from enum import Enum
|
||||
# ========== Enums ==========
|
||||
|
||||
class Rechtsform(str, Enum):
|
||||
"""Rechtsformen für Beteiligte"""
|
||||
"""Legal forms for Beteiligte"""
|
||||
NATUERLICHE_PERSON = ""
|
||||
GMBH = "GmbH"
|
||||
AG = "AG"
|
||||
@@ -29,7 +29,7 @@ class Rechtsform(str, Enum):
|
||||
|
||||
|
||||
class SyncStatus(str, Enum):
|
||||
"""Sync Status für EspoCRM Entities"""
|
||||
"""Sync status for EspoCRM entities (Beteiligte)"""
|
||||
PENDING_SYNC = "pending_sync"
|
||||
SYNCING = "syncing"
|
||||
CLEAN = "clean"
|
||||
@@ -38,8 +38,30 @@ class SyncStatus(str, Enum):
|
||||
PERMANENTLY_FAILED = "permanently_failed"
|
||||
|
||||
|
||||
class FileStatus(str, Enum):
|
||||
"""Valid values for CDokumente.fileStatus field"""
|
||||
NEW = "new"
|
||||
CHANGED = "changed"
|
||||
SYNCED = "synced"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class XAISyncStatus(str, Enum):
|
||||
"""Valid values for CDokumente.xaiSyncStatus field"""
|
||||
NO_SYNC = "no_sync" # Entity has no xAI collections
|
||||
PENDING_SYNC = "pending_sync" # Sync in progress (locked)
|
||||
CLEAN = "clean" # Synced successfully
|
||||
UNCLEAN = "unclean" # Needs re-sync (file changed)
|
||||
FAILED = "failed" # Sync failed (see xaiSyncError)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class SalutationType(str, Enum):
|
||||
"""Anredetypen"""
|
||||
"""Salutation types"""
|
||||
HERR = "Herr"
|
||||
FRAU = "Frau"
|
||||
DIVERS = "Divers"
|
||||
|
||||
@@ -1,51 +1,58 @@
|
||||
"""
|
||||
Redis Client Factory
|
||||
|
||||
Zentralisierte Redis-Client-Verwaltung mit:
|
||||
- Singleton Pattern
|
||||
- Connection Pooling
|
||||
- Automatic Reconnection
|
||||
- Health Checks
|
||||
Centralized Redis client management with:
|
||||
- Singleton pattern
|
||||
- Connection pooling
|
||||
- Automatic reconnection
|
||||
- Health checks
|
||||
"""
|
||||
|
||||
import redis
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from services.exceptions import RedisConnectionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from services.logging_utils import get_service_logger
|
||||
|
||||
|
||||
class RedisClientFactory:
|
||||
"""
|
||||
Singleton Factory für Redis Clients.
|
||||
Singleton factory for Redis clients.
|
||||
|
||||
Vorteile:
|
||||
- Eine zentrale Konfiguration
|
||||
- Connection Pooling
|
||||
- Lazy Initialization
|
||||
- Besseres Error Handling
|
||||
Benefits:
|
||||
- Centralized configuration
|
||||
- Connection pooling
|
||||
- Lazy initialization
|
||||
- Better error handling
|
||||
"""
|
||||
|
||||
_instance: Optional[redis.Redis] = None
|
||||
_connection_pool: Optional[redis.ConnectionPool] = None
|
||||
_logger = None
|
||||
|
||||
@classmethod
|
||||
def _get_logger(cls):
|
||||
"""Get logger instance (lazy initialization)"""
|
||||
if cls._logger is None:
|
||||
cls._logger = get_service_logger('redis_factory', None)
|
||||
return cls._logger
|
||||
|
||||
@classmethod
|
||||
def get_client(cls, strict: bool = False) -> Optional[redis.Redis]:
|
||||
"""
|
||||
Gibt Redis Client zurück (erstellt wenn nötig).
|
||||
Return Redis client (creates if needed).
|
||||
|
||||
Args:
|
||||
strict: Wenn True, wirft Exception bei Verbindungsfehlern.
|
||||
Wenn False, gibt None zurück (für optionale Redis-Nutzung).
|
||||
strict: If True, raises exception on connection failures.
|
||||
If False, returns None (for optional Redis usage).
|
||||
|
||||
Returns:
|
||||
Redis client oder None (wenn strict=False und Verbindung fehlschlägt)
|
||||
Redis client or None (if strict=False and connection fails)
|
||||
|
||||
Raises:
|
||||
RedisConnectionError: Wenn strict=True und Verbindung fehlschlägt
|
||||
RedisConnectionError: If strict=True and connection fails
|
||||
"""
|
||||
logger = cls._get_logger()
|
||||
if cls._instance is None:
|
||||
try:
|
||||
cls._instance = cls._create_client()
|
||||
@@ -65,14 +72,15 @@ class RedisClientFactory:
|
||||
@classmethod
|
||||
def _create_client(cls) -> redis.Redis:
|
||||
"""
|
||||
Erstellt neuen Redis Client mit Connection Pool.
|
||||
Create new Redis client with connection pool.
|
||||
|
||||
Returns:
|
||||
Configured Redis client
|
||||
|
||||
Raises:
|
||||
redis.ConnectionError: Bei Verbindungsproblemen
|
||||
redis.ConnectionError: On connection problems
|
||||
"""
|
||||
logger = cls._get_logger()
|
||||
# Load configuration from environment
|
||||
redis_host = os.getenv('REDIS_HOST', 'localhost')
|
||||
redis_port = int(os.getenv('REDIS_PORT', '6379'))
|
||||
@@ -94,7 +102,7 @@ class RedisClientFactory:
|
||||
socket_timeout=redis_timeout,
|
||||
socket_connect_timeout=redis_timeout,
|
||||
max_connections=redis_max_connections,
|
||||
decode_responses=True # Auto-decode bytes zu strings
|
||||
decode_responses=True # Auto-decode bytes to strings
|
||||
)
|
||||
|
||||
# Create client from pool
|
||||
@@ -108,10 +116,11 @@ class RedisClientFactory:
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
"""
|
||||
Reset factory state (hauptsächlich für Tests).
|
||||
Reset factory state (mainly for tests).
|
||||
|
||||
Schließt bestehende Verbindungen und setzt Singleton zurück.
|
||||
Closes existing connections and resets singleton.
|
||||
"""
|
||||
logger = cls._get_logger()
|
||||
if cls._instance:
|
||||
try:
|
||||
cls._instance.close()
|
||||
@@ -131,11 +140,12 @@ class RedisClientFactory:
|
||||
@classmethod
|
||||
def health_check(cls) -> bool:
|
||||
"""
|
||||
Prüft Redis-Verbindung.
|
||||
Check Redis connection.
|
||||
|
||||
Returns:
|
||||
True wenn Redis erreichbar, False sonst
|
||||
True if Redis is reachable, False otherwise
|
||||
"""
|
||||
logger = cls._get_logger()
|
||||
try:
|
||||
client = cls.get_client(strict=False)
|
||||
if client is None:
|
||||
@@ -150,11 +160,12 @@ class RedisClientFactory:
|
||||
@classmethod
|
||||
def get_info(cls) -> Optional[dict]:
|
||||
"""
|
||||
Gibt Redis Server Info zurück (für Monitoring).
|
||||
Return Redis server info (for monitoring).
|
||||
|
||||
Returns:
|
||||
Redis info dict oder None bei Fehler
|
||||
Redis info dict or None on error
|
||||
"""
|
||||
logger = cls._get_logger()
|
||||
try:
|
||||
client = cls.get_client(strict=False)
|
||||
if client is None:
|
||||
@@ -170,22 +181,22 @@ class RedisClientFactory:
|
||||
|
||||
def get_redis_client(strict: bool = False) -> Optional[redis.Redis]:
|
||||
"""
|
||||
Convenience function für Redis Client.
|
||||
Convenience function for Redis client.
|
||||
|
||||
Args:
|
||||
strict: Wenn True, wirft Exception bei Fehler
|
||||
strict: If True, raises exception on error
|
||||
|
||||
Returns:
|
||||
Redis client oder None
|
||||
Redis client or None
|
||||
"""
|
||||
return RedisClientFactory.get_client(strict=strict)
|
||||
|
||||
|
||||
def is_redis_available() -> bool:
|
||||
"""
|
||||
Prüft ob Redis verfügbar ist.
|
||||
Check if Redis is available.
|
||||
|
||||
Returns:
|
||||
True wenn Redis erreichbar
|
||||
True if Redis is reachable
|
||||
"""
|
||||
return RedisClientFactory.health_check()
|
||||
|
||||
Reference in New Issue
Block a user