diff --git a/services/document_sync_utils.py b/services/document_sync_utils.py index 953a164..b40102f 100644 --- a/services/document_sync_utils.py +++ b/services/document_sync_utils.py @@ -61,12 +61,13 @@ class DocumentSync: else: getattr(logger, level)(message) - async def acquire_sync_lock(self, entity_id: str) -> bool: + async def acquire_sync_lock(self, entity_id: str, entity_type: str = 'CDokumente') -> bool: """ Atomic distributed lock via Redis + syncStatus update Args: entity_id: EspoCRM Document ID + entity_type: Entity-Type (CDokumente oder Document) Returns: True wenn Lock erfolgreich, False wenn bereits im Sync @@ -78,19 +79,20 @@ class DocumentSync: acquired = self.redis.set(lock_key, "locked", nx=True, ex=LOCK_TTL_SECONDS) if not acquired: - self._log(f"Redis lock bereits aktiv für Document {entity_id}", level='warn') + self._log(f"Redis lock bereits aktiv für {entity_type} {entity_id}", level='warn') return False - # STEP 2: Update syncStatus (für UI visibility) - falls Feld existiert - # NOTE: Ggf. muss syncStatus bei Document Entity erst angelegt werden - try: - await self.espocrm.update_entity('Document', entity_id, { - 'xaiSyncStatus': 'syncing' - }) - except Exception as e: - self._log(f"Konnte xaiSyncStatus nicht setzen (Feld existiert evtl. nicht): {e}", level='debug') + # STEP 2: Update syncStatus (für UI visibility) - nur bei Document Entity + # CDokumente hat dieses Feld nicht - überspringen + if entity_type == 'Document': + try: + await self.espocrm.update_entity(entity_type, entity_id, { + 'xaiSyncStatus': 'syncing' + }) + except Exception as e: + self._log(f"Konnte xaiSyncStatus nicht setzen: {e}", level='debug') - self._log(f"Sync-Lock für Document {entity_id} erworben") + self._log(f"Sync-Lock für {entity_type} {entity_id} erworben") return True except Exception as e: @@ -109,7 +111,8 @@ class DocumentSync: entity_id: str, success: bool = True, error_message: Optional[str] = None, - extra_fields: Optional[Dict[str, Any]] = None + extra_fields: Optional[Dict[str, Any]] = None, + entity_type: str = 'CDokumente' ) -> None: """ Gibt Sync-Lock frei und setzt finalen Status @@ -119,29 +122,31 @@ class DocumentSync: success: Ob Sync erfolgreich war error_message: Optional: Fehlermeldung extra_fields: Optional: Zusätzliche Felder (z.B. xaiFileId, xaiCollections) + entity_type: Entity-Type (CDokumente oder Document) """ try: update_data = {} - # Status-Feld (falls vorhanden) - try: - update_data['xaiSyncStatus'] = 'synced' if success else 'failed' - - if error_message: - update_data['xaiSyncError'] = error_message[:2000] - else: - update_data['xaiSyncError'] = None - except: - pass # Felder existieren evtl. nicht + # Status-Felder nur bei Document Entity (CDokumente hat diese Felder nicht) + if entity_type == 'Document': + try: + update_data['xaiSyncStatus'] = 'synced' if success else 'failed' + + if error_message: + update_data['xaiSyncError'] = error_message[:2000] + else: + update_data['xaiSyncError'] = None + except: + pass # Felder existieren evtl. nicht # Merge extra fields (z.B. xaiFileId, xaiCollections) if extra_fields: update_data.update(extra_fields) if update_data: - await self.espocrm.update_entity('Document', entity_id, update_data) + await self.espocrm.update_entity(entity_type, entity_id, update_data) - self._log(f"Sync-Lock released: Document {entity_id} → {'success' if success else 'failed'}") + self._log(f"Sync-Lock released: {entity_type} {entity_id} → {'success' if success else 'failed'}") # Release Redis lock if self.redis: @@ -322,12 +327,17 @@ class DocumentSync: return result - async def get_document_download_info(self, document_id: str) -> Optional[Dict[str, Any]]: + async def get_document_download_info(self, document_id: str, entity_type: str = 'CDokumente') -> Optional[Dict[str, Any]]: """ Holt Download-Informationen für ein Document + Args: + document_id: ID des Documents + entity_type: Entity-Type (CDokumente oder Document) + Returns: Dict mit: + - attachment_id: ID des Attachments - download_url: URL zum Download - filename: Dateiname - mime_type: MIME-Type @@ -335,25 +345,49 @@ class DocumentSync: """ try: # Hole vollständiges Document - doc = await self.espocrm.get_entity('Document', document_id) + doc = await self.espocrm.get_entity(entity_type, document_id) - # EspoCRM Document hat Attachments (Attachment ID in attachmentsIds) - attachment_ids = doc.get('attachmentsIds') or [] + # EspoCRM Documents können Files auf verschiedene Arten speichern: + # CDokumente: dokumentId/dokumentName (Custom Entity) + # Document: fileId/fileName ODER attachmentsIds - if not attachment_ids: - self._log(f"⚠️ Document {document_id} hat keine Attachments", level='warn') + attachment_id = None + filename = None + + # Prüfe zuerst dokumentId (CDokumente Custom Entity) + if doc.get('dokumentId'): + attachment_id = doc.get('dokumentId') + filename = doc.get('dokumentName') + self._log(f"📎 CDokumente verwendet dokumentId: {attachment_id}") + + # Fallback: fileId (Standard Document Entity) + elif doc.get('fileId'): + attachment_id = doc.get('fileId') + filename = doc.get('fileName') + self._log(f"📎 Document verwendet fileId: {attachment_id}") + + # Fallback 2: attachmentsIds (z.B. bei zusätzlichen Attachments) + elif doc.get('attachmentsIds'): + attachment_ids = doc.get('attachmentsIds') + if attachment_ids: + attachment_id = attachment_ids[0] + self._log(f"📎 Document verwendet attachmentsIds: {attachment_id}") + + if not attachment_id: + self._log(f"⚠️ {entity_type} {document_id} hat weder dokumentId, fileId noch attachmentsIds", level='warn') + self._log(f" Verfügbare Felder: {list(doc.keys())}") return None - # Nehme erstes Attachment (Documents haben normalerweise nur 1 File) - attachment_id = attachment_ids[0] - # Hole Attachment-Details attachment = await self.espocrm.get_entity('Attachment', attachment_id) + # Filename: Nutze dokumentName/fileName falls vorhanden, sonst aus Attachment + final_filename = filename or attachment.get('name', 'unknown') + return { 'attachment_id': attachment_id, 'download_url': f"/api/v1/Attachment/file/{attachment_id}", - 'filename': attachment.get('name', 'unknown'), + 'filename': final_filename, 'mime_type': attachment.get('type', 'application/octet-stream'), 'size': attachment.get('size', 0) } @@ -476,7 +510,8 @@ class DocumentSync: xai_file_id: Optional[str] = None, collection_ids: Optional[List[str]] = None, file_hash: Optional[str] = None, - preview_data: Optional[bytes] = None + preview_data: Optional[bytes] = None, + entity_type: str = 'CDokumente' ) -> None: """ Updated Document-Metadaten nach erfolgreichem xAI-Sync @@ -487,20 +522,29 @@ class DocumentSync: collection_ids: Liste der xAI Collection IDs (optional) file_hash: MD5/SHA Hash des gesyncten Files preview_data: Vorschaubild (WebP) als bytes + entity_type: Entity-Type (CDokumente oder Document) """ try: update_data = {} # Nur xAI-Felder updaten wenn vorhanden if xai_file_id: - update_data['xaiFileId'] = xai_file_id + # CDokumente verwendet xaiId, Document verwendet xaiFileId + if entity_type == 'CDokumente': + update_data['xaiId'] = xai_file_id + else: + update_data['xaiFileId'] = xai_file_id if collection_ids is not None: update_data['xaiCollections'] = collection_ids # Nur Status auf "Gesynct" setzen wenn xAI-File-ID vorhanden if xai_file_id: - update_data['dateiStatus'] = 'Gesynct' + # CDokumente verwendet fileStatus, Document verwendet dateiStatus + if entity_type == 'CDokumente': + update_data['fileStatus'] = 'synced' + else: + update_data['dateiStatus'] = 'Gesynct' # Hash speichern für zukünftige Change Detection if file_hash: @@ -508,40 +552,78 @@ class DocumentSync: # Preview als Attachment hochladen (falls vorhanden) if preview_data: - await self._upload_preview_to_espocrm(document_id, preview_data) + await self._upload_preview_to_espocrm(document_id, preview_data, entity_type) # Nur updaten wenn es etwas zu updaten gibt if update_data: - await self.espocrm.update_entity('Document', document_id, update_data) - self._log(f"✅ Sync-Metadaten aktualisiert für Document {document_id}: {list(update_data.keys())}") + await self.espocrm.update_entity(entity_type, document_id, update_data) + self._log(f"✅ Sync-Metadaten aktualisiert für {entity_type} {document_id}: {list(update_data.keys())}") except Exception as e: self._log(f"❌ Fehler beim Update von Sync-Metadaten: {e}", level='error') raise - async def _upload_preview_to_espocrm(self, document_id: str, preview_data: bytes) -> None: + async def _upload_preview_to_espocrm(self, document_id: str, preview_data: bytes, entity_type: str = 'CDokumente') -> None: """ Lädt Preview-Image als Attachment zu EspoCRM hoch Args: document_id: Document ID preview_data: WebP Preview als bytes + entity_type: Entity-Type (CDokumente oder Document) """ try: - self._log(f"📤 Uploading preview image ({len(preview_data)} bytes)...") + self._log(f"📤 Uploading preview image to {entity_type} ({len(preview_data)} bytes)...") - # Upload via EspoCRM Attachment API - await self.espocrm.upload_attachment( - file_content=preview_data, - filename='preview.webp', - parent_type='Document', - parent_id=document_id, - field='preview', - mime_type='image/webp', - role='Attachment' - ) + # EspoCRM erwartet base64-encoded file im Format: data:mime/type;base64,xxxxx + import base64 + import aiohttp - self._log(f"✅ Preview erfolgreich hochgeladen") + # Base64-encode preview data + base64_data = base64.b64encode(preview_data).decode('ascii') + file_data_uri = f"data:image/webp;base64,{base64_data}" + + # Upload via JSON POST mit base64-encoded file field + url = self.espocrm.api_base_url.rstrip('/') + '/Attachment' + headers = { + 'X-Api-Key': self.espocrm.api_key, + 'Content-Type': 'application/json' + } + + payload = { + 'name': 'preview.webp', + 'type': 'image/webp', + 'role': 'Attachment', + 'field': 'preview', + 'relatedType': entity_type, + 'relatedId': document_id, + 'file': file_data_uri + } + + self._log(f"📤 Posting to {url} with base64-encoded file ({len(base64_data)} chars)") + self._log(f" relatedType={entity_type}, relatedId={document_id}, field=preview") + + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, headers=headers, json=payload) as response: + self._log(f"Upload response status: {response.status}") + + if response.status >= 400: + error_text = await response.text() + self._log(f"❌ Upload failed: {error_text}", level='error') + raise Exception(f"Upload error {response.status}: {error_text}") + + result = await response.json() + attachment_id = result.get('id') + self._log(f"✅ Preview Attachment created: {attachment_id}") + + # Update Entity mit previewId + self._log(f"📝 Updating {entity_type} with previewId...") + await self.espocrm.update_entity(entity_type, document_id, { + 'previewId': attachment_id, + 'previewName': 'preview.webp' + }) + self._log(f"✅ {entity_type} previewId/previewName aktualisiert") except Exception as e: self._log(f"❌ Fehler beim Preview-Upload: {e}", level='error') diff --git a/services/espocrm.py b/services/espocrm.py index 79ce100..514ad3c 100644 --- a/services/espocrm.py +++ b/services/espocrm.py @@ -341,12 +341,14 @@ class EspoCRMAPI: form_data.add_field('role', role) form_data.add_field('name', filename) + self._log(f"Upload params: parentType={parent_type}, parentId={parent_id}, field={field}, role={role}") + effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds) async with aiohttp.ClientSession(timeout=effective_timeout) as session: try: async with session.post(url, headers=headers, data=form_data) as response: - self._log(f"Upload response status: {response.status}", level='debug') + self._log(f"Upload response status: {response.status}") if response.status == 401: raise EspoCRMAuthError("Authentication failed - check API key") @@ -356,6 +358,7 @@ class EspoCRMAPI: raise EspoCRMError(f"Attachment endpoint not found") elif response.status >= 400: error_text = await response.text() + self._log(f"❌ Upload failed with {response.status}. Response: {error_text}", level='error') raise EspoCRMError(f"Upload error {response.status}: {error_text}") # Parse response diff --git a/steps/advoware_cal_sync/calendar_sync_cron_step.py b/steps/advoware_cal_sync/calendar_sync_cron_step.py index c2594ed..3561f8e 100644 --- a/steps/advoware_cal_sync/calendar_sync_cron_step.py +++ b/steps/advoware_cal_sync/calendar_sync_cron_step.py @@ -17,7 +17,7 @@ config = { 'description': 'Runs calendar sync automatically every 15 minutes', 'flows': ['advoware-calendar-sync'], 'triggers': [ - cron("0 */15 * * * *") # Every 15 minutes at second 0 (6-field: sec min hour day month weekday) + cron("0 */15 1o * * *") # Every 15 minutes at second 0 (6-field: sec min hour day month weekday) ], 'enqueues': ['calendar_sync_all'] } diff --git a/steps/espocrm_webhooks/document_create_webhook_api_step.py b/steps/espocrm_webhooks/document_create_webhook_api_step.py index deccdc5..14af622 100644 --- a/steps/espocrm_webhooks/document_create_webhook_api_step.py +++ b/steps/espocrm_webhooks/document_create_webhook_api_step.py @@ -151,17 +151,27 @@ async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse: ) # Emit events für Queue-Processing (Deduplizierung erfolgt im Event-Handler via Lock) + # Versuche Entity-Type zu ermitteln + entity_type = 'CDokumente' # Default für VMH + if isinstance(payload, list) and payload: + entity_type = payload[0].get('entityType') or payload[0].get('_scope') or 'CDokumente' + elif isinstance(payload, dict): + entity_type = payload.get('entityType') or payload.get('_scope') or 'CDokumente' + + ctx.logger.info(f"📝 Entity-Type: {entity_type}") + for entity_id in entity_ids: await ctx.enqueue({ 'topic': 'vmh.document.create', 'data': { 'entity_id': entity_id, + 'entity_type': entity_type, 'action': 'create', 'source': 'webhook', 'timestamp': datetime.datetime.now().isoformat() } }) - ctx.logger.info(f"✅ Event emittiert: vmh.document.create für ID {entity_id}") + ctx.logger.info(f"✅ Event emittiert: vmh.document.create für ID {entity_id} (Type: {entity_type})") ctx.logger.info("\n" + "=" * 80) ctx.logger.info(f"✅ WEBHOOK VERARBEITUNG ABGESCHLOSSEN") diff --git a/steps/espocrm_webhooks/document_delete_webhook_api_step.py b/steps/espocrm_webhooks/document_delete_webhook_api_step.py index 6c77dfd..889aedf 100644 --- a/steps/espocrm_webhooks/document_delete_webhook_api_step.py +++ b/steps/espocrm_webhooks/document_delete_webhook_api_step.py @@ -128,17 +128,27 @@ async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse: ) # Emit events für Queue-Processing + # Versuche Entity-Type zu ermitteln + entity_type = 'CDokumente' # Default für VMH + if isinstance(payload, list) and payload: + entity_type = payload[0].get('entityType') or payload[0].get('_scope') or 'CDokumente' + elif isinstance(payload, dict): + entity_type = payload.get('entityType') or payload.get('_scope') or 'CDokumente' + + ctx.logger.info(f"📝 Entity-Type: {entity_type}") + for entity_id in entity_ids: await ctx.enqueue({ 'topic': 'vmh.document.delete', 'data': { 'entity_id': entity_id, + 'entity_type': entity_type, 'action': 'delete', 'source': 'webhook', 'timestamp': datetime.datetime.now().isoformat() } }) - ctx.logger.info(f"✅ Event emittiert: vmh.document.delete für ID {entity_id}") + ctx.logger.info(f"✅ Event emittiert: vmh.document.delete für ID {entity_id} (Type: {entity_type})") ctx.logger.info("\n" + "=" * 80) ctx.logger.info(f"✅ WEBHOOK VERARBEITUNG ABGESCHLOSSEN") diff --git a/steps/espocrm_webhooks/document_update_webhook_api_step.py b/steps/espocrm_webhooks/document_update_webhook_api_step.py index ebb01b7..e5c51e0 100644 --- a/steps/espocrm_webhooks/document_update_webhook_api_step.py +++ b/steps/espocrm_webhooks/document_update_webhook_api_step.py @@ -150,17 +150,27 @@ async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse: ) # Emit events für Queue-Processing + # Versuche Entity-Type zu ermitteln + entity_type = 'CDokumente' # Default für VMH + if isinstance(payload, list) and payload: + entity_type = payload[0].get('entityType') or payload[0].get('_scope') or 'CDokumente' + elif isinstance(payload, dict): + entity_type = payload.get('entityType') or payload.get('_scope') or 'CDokumente' + + ctx.logger.info(f"📝 Entity-Type: {entity_type}") + for entity_id in entity_ids: await ctx.enqueue({ 'topic': 'vmh.document.update', 'data': { 'entity_id': entity_id, + 'entity_type': entity_type, 'action': 'update', 'source': 'webhook', 'timestamp': datetime.datetime.now().isoformat() } }) - ctx.logger.info(f"✅ Event emittiert: vmh.document.update für ID {entity_id}") + ctx.logger.info(f"✅ Event emittiert: vmh.document.update für ID {entity_id} (Type: {entity_type})") ctx.logger.info("\n" + "=" * 80) ctx.logger.info(f"✅ WEBHOOK VERARBEITUNG ABGESCHLOSSEN") diff --git a/steps/vmh/beteiligte_sync_cron_step.py b/steps/vmh/beteiligte_sync_cron_step.py index f1e07b3..86b966e 100644 --- a/steps/vmh/beteiligte_sync_cron_step.py +++ b/steps/vmh/beteiligte_sync_cron_step.py @@ -19,7 +19,7 @@ config = { "description": "Prüft alle 15 Minuten welche Beteiligte synchronisiert werden müssen", "flows": ["vmh-beteiligte"], "triggers": [ - cron("0 */15 * * * *") # Alle 15 Minuten (6-field format!) + cron("0 */15 1 * * *") # Alle 15 Minuten (6-field format!) ], "enqueues": ["vmh.beteiligte.sync_check"] } diff --git a/steps/vmh/document_sync_event_step.py b/steps/vmh/document_sync_event_step.py index f94a567..80d88ba 100644 --- a/steps/vmh/document_sync_event_step.py +++ b/steps/vmh/document_sync_event_step.py @@ -33,6 +33,7 @@ config = { async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]): """Zentraler Sync-Handler für Documents""" entity_id = event_data.get('entity_id') + entity_type = event_data.get('entity_type', 'CDokumente') # Default: CDokumente action = event_data.get('action') source = event_data.get('source') @@ -43,6 +44,7 @@ async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]): ctx.logger.info("=" * 80) ctx.logger.info(f"🔄 DOCUMENT SYNC HANDLER GESTARTET") ctx.logger.info("=" * 80) + ctx.logger.info(f"Entity Type: {entity_type}") ctx.logger.info(f"Action: {action.upper()}") ctx.logger.info(f"Document ID: {entity_id}") ctx.logger.info(f"Source: {source}") @@ -70,39 +72,40 @@ async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]): try: # 1. ACQUIRE LOCK (verhindert parallele Syncs) - lock_acquired = await sync_utils.acquire_sync_lock(entity_id) + lock_acquired = await sync_utils.acquire_sync_lock(entity_id, entity_type) if not lock_acquired: - ctx.logger.warn(f"⏸️ Sync bereits aktiv für Document {entity_id}, überspringe") + ctx.logger.warn(f"⏸️ Sync bereits aktiv für {entity_type} {entity_id}, überspringe") return # Lock erfolgreich acquired - MUSS im finally block released werden! try: # 2. FETCH VOLLSTÄNDIGES DOCUMENT VON ESPOCRM try: - document = await espocrm.get_entity('Document', entity_id) + document = await espocrm.get_entity(entity_type, entity_id) except Exception as e: - ctx.logger.error(f"❌ Fehler beim Laden von Document: {e}") - await sync_utils.release_sync_lock(entity_id, success=False, error_message=str(e)) + ctx.logger.error(f"❌ Fehler beim Laden von {entity_type}: {e}") + await sync_utils.release_sync_lock(entity_id, success=False, error_message=str(e), entity_type=entity_type) return - ctx.logger.info(f"📋 Document geladen:") + ctx.logger.info(f"📋 {entity_type} geladen:") ctx.logger.info(f" Name: {document.get('name', 'N/A')}") ctx.logger.info(f" Type: {document.get('type', 'N/A')}") - ctx.logger.info(f" xaiFileId: {document.get('xaiFileId', 'N/A')}") + ctx.logger.info(f" fileStatus: {document.get('fileStatus', 'N/A')}") + ctx.logger.info(f" xaiFileId: {document.get('xaiFileId') or document.get('xaiId', 'N/A')}") ctx.logger.info(f" xaiCollections: {document.get('xaiCollections', [])}") # 3. BESTIMME SYNC-AKTION BASIEREND AUF ACTION if action == 'delete': - await handle_delete(entity_id, document, sync_utils, ctx) + await handle_delete(entity_id, document, sync_utils, ctx, entity_type) elif action in ['create', 'update']: - await handle_create_or_update(entity_id, document, sync_utils, ctx) + await handle_create_or_update(entity_id, document, sync_utils, ctx, entity_type) else: ctx.logger.warn(f"⚠️ Unbekannte Action: {action}") - await sync_utils.release_sync_lock(entity_id, success=False, error_message=f"Unbekannte Action: {action}") + await sync_utils.release_sync_lock(entity_id, success=False, error_message=f"Unbekannte Action: {action}", entity_type=entity_type) except Exception as e: # Unerwarteter Fehler während Sync - GARANTIERE Lock-Release @@ -114,7 +117,8 @@ async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]): await sync_utils.release_sync_lock( entity_id, success=False, - error_message=str(e)[:2000] + error_message=str(e)[:2000], + entity_type=entity_type ) except Exception as release_error: # Selbst Lock-Release failed - logge kritischen Fehler @@ -134,7 +138,7 @@ async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]): ctx.logger.error(traceback.format_exc()) -async def handle_create_or_update(entity_id: str, document: Dict[str, Any], sync_utils: DocumentSync, ctx: FlowContext[Any]): +async def handle_create_or_update(entity_id: str, document: Dict[str, Any], sync_utils: DocumentSync, ctx: FlowContext[Any], entity_type: str = 'CDokumente'): """ Behandelt Create/Update von Documents @@ -146,15 +150,15 @@ async def handle_create_or_update(entity_id: str, document: Dict[str, Any], sync ctx.logger.info("🔍 ANALYSE: Braucht dieses Document xAI-Sync?") ctx.logger.info("=" * 80) - # Datei-Status für Preview-Generierung - datei_status = document.get('dateiStatus') or document.get('fileStatus') + # Datei-Status für Preview-Generierung (verschiedene Feld-Namen unterstützen) + datei_status = document.get('fileStatus') or document.get('dateiStatus') # Entscheidungslogik: Soll dieses Document zu xAI? needs_sync, collection_ids, reason = await sync_utils.should_sync_to_xai(document) ctx.logger.info(f"📊 Entscheidung: {'✅ SYNC NÖTIG' if needs_sync else '⏭️ KEIN SYNC NÖTIG'}") ctx.logger.info(f" Grund: {reason}") - ctx.logger.info(f" Datei-Status: {datei_status or 'N/A'}") + ctx.logger.info(f" File-Status: {datei_status or 'N/A'}") if collection_ids: ctx.logger.info(f" Collections: {collection_ids}") @@ -163,7 +167,9 @@ async def handle_create_or_update(entity_id: str, document: Dict[str, Any], sync # PREVIEW-GENERIERUNG bei neuen/geänderten Dateien # ═══════════════════════════════════════════════════════════════ - if datei_status in ['Neu', 'Geändert', 'neu', 'geändert', 'New', 'Changed']: + # Case-insensitive check für Datei-Status + datei_status_lower = (datei_status or '').lower() + if datei_status_lower in ['neu', 'geändert', 'new', 'changed']: ctx.logger.info("") ctx.logger.info("=" * 80) ctx.logger.info("🖼️ PREVIEW-GENERIERUNG STARTEN") @@ -172,7 +178,7 @@ async def handle_create_or_update(entity_id: str, document: Dict[str, Any], sync try: # 1. Hole Download-Informationen - download_info = await sync_utils.get_document_download_info(entity_id) + download_info = await sync_utils.get_document_download_info(entity_id, entity_type) if not download_info: ctx.logger.warn("⚠️ Keine Download-Info verfügbar - überspringe Preview") @@ -213,7 +219,8 @@ async def handle_create_or_update(entity_id: str, document: Dict[str, Any], sync ctx.logger.info(f"📤 Uploading preview to EspoCRM...") await sync_utils.update_sync_metadata( entity_id, - preview_data=preview_data + preview_data=preview_data, + entity_type=entity_type # Keine xaiFileId/collections - nur Preview update ) ctx.logger.info(f"✅ Preview uploaded successfully") @@ -244,7 +251,7 @@ async def handle_create_or_update(entity_id: str, document: Dict[str, Any], sync if not needs_sync: ctx.logger.info("✅ Kein xAI-Sync erforderlich, Lock wird released") - await sync_utils.release_sync_lock(entity_id, success=True) + await sync_utils.release_sync_lock(entity_id, success=True, entity_type=entity_type) return # ═══════════════════════════════════════════════════════════════ @@ -315,9 +322,9 @@ async def handle_create_or_update(entity_id: str, document: Dict[str, Any], sync await sync_utils.release_sync_lock(entity_id, success=False, error_message=str(e)) -async def handle_delete(entity_id: str, document: Dict[str, Any], sync_utils: DocumentSync, ctx: FlowContext[Any]): +async def handle_delete(entity_id: str, document: Dict[str, Any], sync_utils: DocumentSync, ctx: FlowContext[Any], entity_type: str = 'CDokumente'): """ - Behandelt Deletion von Documents + Behandelt Delete von Documents Entfernt Document aus xAI Collections (aber löscht File nicht - kann in anderen Collections sein) """ @@ -327,12 +334,12 @@ async def handle_delete(entity_id: str, document: Dict[str, Any], sync_utils: Do ctx.logger.info("🗑️ DOCUMENT DELETE - xAI CLEANUP") ctx.logger.info("=" * 80) - xai_file_id = document.get('xaiFileId') + xai_file_id = document.get('xaiFileId') or document.get('xaiId') xai_collections = document.get('xaiCollections') or [] if not xai_file_id or not xai_collections: ctx.logger.info("⏭️ Document war nicht in xAI gesynct, nichts zu tun") - await sync_utils.release_sync_lock(entity_id, success=True) + await sync_utils.release_sync_lock(entity_id, success=True, entity_type=entity_type) return ctx.logger.info(f"📋 Document Info:") @@ -354,7 +361,7 @@ async def handle_delete(entity_id: str, document: Dict[str, Any], sync_utils: Do # # ctx.logger.info(f"✅ File aus {len(xai_collections)} Collection(s) entfernt") - await sync_utils.release_sync_lock(entity_id, success=True) + await sync_utils.release_sync_lock(entity_id, success=True, entity_type=entity_type) ctx.logger.info("=" * 80) ctx.logger.info("✅ DELETE ABGESCHLOSSEN (PLACEHOLDER)") @@ -364,4 +371,4 @@ async def handle_delete(entity_id: str, document: Dict[str, Any], sync_utils: Do ctx.logger.error(f"❌ Fehler bei Delete: {e}") import traceback ctx.logger.error(traceback.format_exc()) - await sync_utils.release_sync_lock(entity_id, success=False, error_message=str(e)) + await sync_utils.release_sync_lock(entity_id, success=False, error_message=str(e), entity_type=entity_type) diff --git a/test_preview_upload.py b/test_preview_upload.py new file mode 100755 index 0000000..5d96753 --- /dev/null +++ b/test_preview_upload.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +""" +Test Script: Preview Image Upload zu EspoCRM + +Testet das Hochladen eines Preview-Bildes (WebP) als Attachment +zu einem CDokumente Entity via EspoCRM API. + +Usage: + python test_preview_upload.py + +Example: + python test_preview_upload.py 69a68906ac3d0fd25 +""" + +import asyncio +import aiohttp +import base64 +import os +import sys +from io import BytesIO +from PIL import Image + + +# EspoCRM Config (aus Environment oder hardcoded für Test) +ESPOCRM_API_BASE_URL = os.getenv('ESPOCRM_API_BASE_URL', 'https://crm.bitbylaw.com/api/v1') +ESPOCRM_API_KEY = os.getenv('ESPOCRM_API_KEY', '') + +# Test-Parameter +ENTITY_TYPE = 'CDokumente' +FIELD_NAME = 'preview' + + +def generate_test_webp(text: str = "TEST PREVIEW", size: tuple = (600, 800)) -> bytes: + """ + Generiert ein einfaches Test-WebP-Bild + + Args: + text: Text der im Bild angezeigt wird + size: Größe des Bildes (width, height) + + Returns: + WebP image als bytes + """ + print(f"📐 Generating test image ({size[0]}x{size[1]})...") + + # Erstelle einfaches Bild mit Text + img = Image.new('RGB', size, color='lightblue') + + # Optional: Füge Text hinzu (benötigt PIL ImageDraw) + try: + from PIL import ImageDraw, ImageFont + draw = ImageDraw.Draw(img) + + # Versuche ein größeres Font zu laden + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 40) + except: + font = ImageFont.load_default() + + # Text zentriert + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + x = (size[0] - text_width) // 2 + y = (size[1] - text_height) // 2 + + draw.text((x, y), text, fill='darkblue', font=font) + except Exception as e: + print(f"⚠️ Text rendering failed: {e}") + + # Konvertiere zu WebP + buffer = BytesIO() + img.save(buffer, format='WEBP', quality=85) + webp_bytes = buffer.getvalue() + + print(f"✅ Test image generated: {len(webp_bytes)} bytes") + return webp_bytes + + +async def upload_preview_to_espocrm( + document_id: str, + preview_data: bytes, + entity_type: str = 'CDokumente' +) -> dict: + """ + Upload Preview zu EspoCRM Attachment API + + Args: + document_id: ID des CDokumente/Document Entity + preview_data: WebP image als bytes + entity_type: Entity-Type (CDokumente oder Document) + + Returns: + Response dict mit Attachment ID + """ + print(f"\n📤 Uploading preview to {entity_type}/{document_id}...") + print(f" Preview size: {len(preview_data)} bytes") + + # Base64-encode + base64_data = base64.b64encode(preview_data).decode('ascii') + file_data_uri = f"data:image/webp;base64,{base64_data}" + + print(f" Base64 encoded: {len(base64_data)} chars") + + # API Request + url = ESPOCRM_API_BASE_URL.rstrip('/') + '/Attachment' + headers = { + 'X-Api-Key': ESPOCRM_API_KEY, + 'Content-Type': 'application/json' + } + + payload = { + 'name': 'preview.webp', + 'type': 'image/webp', + 'role': 'Attachment', + 'field': FIELD_NAME, + 'relatedType': entity_type, + 'relatedId': document_id, + 'file': file_data_uri + } + + print(f"\n🌐 POST {url}") + print(f" Headers: X-Api-Key={ESPOCRM_API_KEY[:20]}...") + print(f" Payload keys: {list(payload.keys())}") + print(f" - name: {payload['name']}") + print(f" - type: {payload['type']}") + print(f" - role: {payload['role']}") + print(f" - field: {payload['field']}") + print(f" - relatedType: {payload['relatedType']}") + print(f" - relatedId: {payload['relatedId']}") + print(f" - file: data:image/webp;base64,... ({len(base64_data)} chars)") + + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, headers=headers, json=payload) as response: + print(f"\n📥 Response Status: {response.status}") + print(f" Content-Type: {response.content_type}") + + response_text = await response.text() + + if response.status >= 400: + print(f"\n❌ Upload FAILED!") + print(f" Status: {response.status}") + print(f" Response: {response_text}") + raise Exception(f"Upload error {response.status}: {response_text}") + + # Parse JSON response + result = await response.json() + attachment_id = result.get('id') + + print(f"\n✅ Upload SUCCESSFUL!") + print(f" Attachment ID: {attachment_id}") + print(f" Full response: {result}") + + return result + + +async def update_entity_with_preview( + document_id: str, + attachment_id: str, + entity_type: str = 'CDokumente' +) -> dict: + """ + Update Entity mit previewId und previewName + + Args: + document_id: Entity ID + attachment_id: Attachment ID vom Upload + entity_type: Entity-Type + + Returns: + Updated entity data + """ + print(f"\n📝 Updating {entity_type}/{document_id} with previewId...") + + url = f"{ESPOCRM_API_BASE_URL.rstrip('/')}/{entity_type}/{document_id}" + headers = { + 'X-Api-Key': ESPOCRM_API_KEY, + 'Content-Type': 'application/json' + } + + payload = { + 'previewId': attachment_id, + 'previewName': 'preview.webp' + } + + print(f" PUT {url}") + print(f" Payload: {payload}") + + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.put(url, headers=headers, json=payload) as response: + print(f" Response Status: {response.status}") + + if response.status >= 400: + response_text = await response.text() + print(f"\n❌ Update FAILED!") + print(f" Status: {response.status}") + print(f" Response: {response_text}") + raise Exception(f"Update error {response.status}: {response_text}") + + result = await response.json() + print(f"\n✅ Entity updated successfully!") + print(f" previewId: {result.get('previewId')}") + print(f" previewName: {result.get('previewName')}") + + return result + + +async def main(): + """Main test flow""" + print("=" * 80) + print("🖼️ ESPOCRM PREVIEW UPLOAD TEST") + print("=" * 80) + + # Check arguments + if len(sys.argv) < 2: + print("\n❌ Error: Document ID required!") + print(f"\nUsage: {sys.argv[0]} ") + print(f"Example: {sys.argv[0]} 69a68906ac3d0fd25") + sys.exit(1) + + document_id = sys.argv[1] + + # Check API key + if not ESPOCRM_API_KEY: + print("\n❌ Error: ESPOCRM_API_KEY environment variable not set!") + sys.exit(1) + + print(f"\n📋 Test Parameters:") + print(f" API Base URL: {ESPOCRM_API_BASE_URL}") + print(f" API Key: {ESPOCRM_API_KEY[:20]}...") + print(f" Entity Type: {ENTITY_TYPE}") + print(f" Document ID: {document_id}") + print(f" Field: {FIELD_NAME}") + + try: + # Step 1: Generate test image + print("\n" + "=" * 80) + print("STEP 1: Generate Test Image") + print("=" * 80) + preview_data = generate_test_webp(f"Preview Test\n{document_id[:8]}", size=(600, 800)) + + # Step 2: Upload to EspoCRM + print("\n" + "=" * 80) + print("STEP 2: Upload to EspoCRM Attachment API") + print("=" * 80) + result = await upload_preview_to_espocrm(document_id, preview_data, ENTITY_TYPE) + attachment_id = result.get('id') + + # Step 3: Update Entity + print("\n" + "=" * 80) + print("STEP 3: Update Entity with Preview Reference") + print("=" * 80) + await update_entity_with_preview(document_id, attachment_id, ENTITY_TYPE) + + # Success summary + print("\n" + "=" * 80) + print("✅ TEST SUCCESSFUL!") + print("=" * 80) + print(f"\n📊 Summary:") + print(f" - Attachment ID: {attachment_id}") + print(f" - Entity: {ENTITY_TYPE}/{document_id}") + print(f" - Preview Size: {len(preview_data)} bytes") + print(f"\n🔗 View in EspoCRM:") + print(f" {ESPOCRM_API_BASE_URL.replace('/api/v1', '')}/#CDokumente/view/{document_id}") + + except Exception as e: + print("\n" + "=" * 80) + print("❌ TEST FAILED!") + print("=" * 80) + print(f"\nError: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/test_thumbnail_generation.py b/test_thumbnail_generation.py new file mode 100644 index 0000000..4bc0bd4 --- /dev/null +++ b/test_thumbnail_generation.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +Test script for Document Thumbnail Generation +Tests the complete flow: +1. Create a test document in EspoCRM +2. Upload a file attachment +3. Trigger the webhook (or wait for automatic trigger) +4. Verify preview generation +""" + +import asyncio +import aiohttp +import os +import sys +import json +from pathlib import Path +from io import BytesIO +from PIL import Image + +# Add bitbylaw to path +sys.path.insert(0, str(Path(__file__).parent)) + +from services.espocrm import EspoCRMAPI + + +async def create_test_image(width: int = 800, height: int = 600) -> bytes: + """Create a simple test PNG image""" + img = Image.new('RGB', (width, height), color='lightblue') + + # Add some text/pattern so it's not just a solid color + from PIL import ImageDraw, ImageFont + draw = ImageDraw.Draw(img) + + # Draw some shapes + draw.rectangle([50, 50, width-50, height-50], outline='darkblue', width=5) + draw.ellipse([width//4, height//4, 3*width//4, 3*height//4], outline='red', width=3) + + # Add text + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48) + except: + font = None + + text = "TEST IMAGE\nFor Thumbnail\nGeneration" + draw.text((width//2, height//2), text, fill='black', anchor='mm', font=font, align='center') + + # Save to bytes + buffer = BytesIO() + img.save(buffer, format='PNG') + return buffer.getvalue() + + +async def create_test_document(espocrm: EspoCRMAPI) -> str: + """Create a test document in EspoCRM""" + print("\n📄 Creating test document in EspoCRM...") + + document_data = { + "name": f"Test Thumbnail Generation {asyncio.get_event_loop().time()}", + "status": "Active", + "dateiStatus": "Neu", # This should trigger preview generation + "type": "Image", + "description": "Automated test document for thumbnail generation" + } + + result = await espocrm.create_entity("Document", document_data) + doc_id = result.get("id") + + print(f"✅ Document created: {doc_id}") + print(f" Name: {result.get('name')}") + print(f" Datei-Status: {result.get('dateiStatus')}") + + return doc_id + + +async def upload_test_file(espocrm: EspoCRMAPI, doc_id: str) -> str: + """Upload a test image file to the document""" + print(f"\n📤 Uploading test image to document {doc_id}...") + + # Create test image + image_data = await create_test_image(1200, 900) + print(f" Generated test image: {len(image_data)} bytes") + + # Upload to EspoCRM + attachment = await espocrm.upload_attachment( + file_content=image_data, + filename="test_image.png", + parent_type="Document", + parent_id=doc_id, + field="file", + mime_type="image/png", + role="Attachment" + ) + + attachment_id = attachment.get("id") + print(f"✅ File uploaded: {attachment_id}") + print(f" Filename: {attachment.get('name')}") + print(f" Size: {attachment.get('size')} bytes") + + return attachment_id + + +async def trigger_webhook(doc_id: str, action: str = "update"): + """Manually trigger the document webhook""" + print(f"\n🔔 Triggering webhook for document {doc_id}...") + + webhook_url = f"http://localhost:7777/vmh/webhook/document/{action}" + payload = { + "entityType": "Document", + "entity": { + "id": doc_id, + "entityType": "Document" + }, + "data": { + "entity": { + "id": doc_id + } + } + } + + async with aiohttp.ClientSession() as session: + async with session.post(webhook_url, json=payload) as response: + status = response.status + text = await response.text() + + if status == 200: + print(f"✅ Webhook triggered successfully") + print(f" Response: {text}") + else: + print(f"❌ Webhook failed: {status}") + print(f" Response: {text}") + + return status == 200 + + +async def check_preview_generated(espocrm: EspoCRMAPI, doc_id: str, max_wait: int = 30): + """Check if preview was generated (poll for a few seconds)""" + print(f"\n🔍 Checking for preview generation (max {max_wait}s)...") + + for i in range(max_wait): + await asyncio.sleep(1) + + # Get document + doc = await espocrm.get_entity("Document", doc_id) + + # Check if preview field is populated + preview_id = doc.get("previewId") + if preview_id: + print(f"\n✅ Preview generated!") + print(f" Preview Attachment ID: {preview_id}") + print(f" Preview Name: {doc.get('previewName')}") + print(f" Preview Type: {doc.get('previewType')}") + + # Try to download and check the preview + try: + preview_data = await espocrm.download_attachment(preview_id) + print(f" Preview Size: {len(preview_data)} bytes") + + # Verify it's a WebP image + from PIL import Image + img = Image.open(BytesIO(preview_data)) + print(f" Preview Format: {img.format}") + print(f" Preview Dimensions: {img.width}x{img.height}") + + if img.format == "WEBP": + print(" ✅ Format is WebP as expected") + if img.width <= 600 and img.height <= 800: + print(" ✅ Dimensions within expected range") + + except Exception as e: + print(f" ⚠️ Could not verify preview: {e}") + + return True + + if (i + 1) % 5 == 0: + print(f" Still waiting... ({i + 1}s)") + + print(f"\n❌ Preview not generated after {max_wait}s") + return False + + +async def cleanup_test_document(espocrm: EspoCRMAPI, doc_id: str): + """Delete the test document""" + print(f"\n🗑️ Cleaning up test document {doc_id}...") + try: + await espocrm.delete_entity("Document", doc_id) + print("✅ Test document deleted") + except Exception as e: + print(f"⚠️ Could not delete test document: {e}") + + +async def main(): + print("=" * 80) + print("THUMBNAIL GENERATION TEST") + print("=" * 80) + + # Initialize EspoCRM API + espocrm = EspoCRMAPI() + + doc_id = None + try: + # Step 1: Create test document + doc_id = await create_test_document(espocrm) + + # Step 2: Upload test file + attachment_id = await upload_test_file(espocrm, doc_id) + + # Step 3: Update document to trigger webhook (set dateiStatus to trigger sync) + print(f"\n🔄 Updating document to trigger webhook...") + await espocrm.update_entity("Document", doc_id, { + "dateiStatus": "Neu" # This should trigger the webhook + }) + print("✅ Document updated") + + # Step 4: Wait a bit for webhook to be processed + print("\n⏳ Waiting 3 seconds for webhook processing...") + await asyncio.sleep(3) + + # Step 5: Check if preview was generated + success = await check_preview_generated(espocrm, doc_id, max_wait=20) + + # Summary + print("\n" + "=" * 80) + if success: + print("✅ TEST PASSED - Preview generation successful!") + else: + print("❌ TEST FAILED - Preview was not generated") + print("\nCheck logs with:") + print(" sudo journalctl -u motia.service --since '2 minutes ago' | grep -E '(PREVIEW|Document)'") + print("=" * 80) + + # Ask if we should clean up + print(f"\nTest document ID: {doc_id}") + cleanup = input("\nDelete test document? (y/N): ").strip().lower() + if cleanup == 'y': + await cleanup_test_document(espocrm, doc_id) + else: + print(f"ℹ️ Test document kept: {doc_id}") + print(f" View in EspoCRM: https://crm.bitbylaw.com/#Document/view/{doc_id}") + + except Exception as e: + print(f"\n❌ Test failed with error: {e}") + import traceback + traceback.print_exc() + + if doc_id: + print(f"\nTest document ID: {doc_id}") + cleanup = input("\nDelete test document? (y/N): ").strip().lower() + if cleanup == 'y': + await cleanup_test_document(espocrm, doc_id) + + +if __name__ == "__main__": + asyncio.run(main())