- Improved logging for file uploads in EspoCRMAPI to include upload parameters and error details. - Updated cron job configurations for calendar sync and participant sync to trigger every 15 minutes on the first minute of the hour. - Enhanced document create, delete, and update webhook handlers to determine and log the entity type. - Refactored document sync event handler to include entity type in sync operations and logging. - Added a new test script for uploading preview images to EspoCRM and verifying the upload process. - Created a test script for document thumbnail generation, including document creation, file upload, webhook triggering, and preview verification.
418 lines
15 KiB
Python
418 lines
15 KiB
Python
"""EspoCRM API client for Motia III"""
|
|
import aiohttp
|
|
import asyncio
|
|
import logging
|
|
import redis
|
|
import os
|
|
from typing import Optional, Dict, Any, List
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EspoCRMError(Exception):
|
|
"""Base exception for EspoCRM API errors"""
|
|
pass
|
|
|
|
|
|
class EspoCRMAuthError(EspoCRMError):
|
|
"""Authentication error"""
|
|
pass
|
|
|
|
|
|
class EspoCRMAPI:
|
|
"""
|
|
EspoCRM API Client for BitByLaw integration.
|
|
|
|
Supports:
|
|
- API Key authentication (X-Api-Key header)
|
|
- Standard REST operations (GET, POST, PUT, DELETE)
|
|
- Entity management (CBeteiligte, CVmhErstgespraech, etc.)
|
|
|
|
Environment variables required:
|
|
- ESPOCRM_API_BASE_URL (e.g., https://crm.bitbylaw.com/api/v1)
|
|
- ESPOCRM_API_KEY (Marvin API key)
|
|
- ESPOCRM_API_TIMEOUT_SECONDS (optional, default: 30)
|
|
- REDIS_HOST, REDIS_PORT, REDIS_DB_ADVOWARE_CACHE (for caching)
|
|
"""
|
|
|
|
def __init__(self, context=None):
|
|
"""
|
|
Initialize EspoCRM API client.
|
|
|
|
Args:
|
|
context: Motia FlowContext for logging (optional)
|
|
"""
|
|
self.context = context
|
|
self._log("EspoCRMAPI initializing", level='debug')
|
|
|
|
# Load configuration from environment
|
|
self.api_base_url = os.getenv('ESPOCRM_API_BASE_URL', 'https://crm.bitbylaw.com/api/v1')
|
|
self.api_key = os.getenv('ESPOCRM_API_KEY', '')
|
|
self.api_timeout_seconds = int(os.getenv('ESPOCRM_API_TIMEOUT_SECONDS', '30'))
|
|
|
|
if not self.api_key:
|
|
raise EspoCRMAuthError("ESPOCRM_API_KEY not configured in environment")
|
|
|
|
self._log(f"EspoCRM API initialized with base URL: {self.api_base_url}")
|
|
|
|
# Optional Redis for caching/rate limiting
|
|
try:
|
|
redis_host = os.getenv('REDIS_HOST', 'localhost')
|
|
redis_port = int(os.getenv('REDIS_PORT', '6379'))
|
|
redis_db = int(os.getenv('REDIS_DB_ADVOWARE_CACHE', '1'))
|
|
redis_timeout = int(os.getenv('REDIS_TIMEOUT_SECONDS', '5'))
|
|
|
|
self.redis_client = redis.Redis(
|
|
host=redis_host,
|
|
port=redis_port,
|
|
db=redis_db,
|
|
socket_timeout=redis_timeout,
|
|
socket_connect_timeout=redis_timeout,
|
|
decode_responses=True
|
|
)
|
|
self.redis_client.ping()
|
|
self._log("Connected to Redis for EspoCRM operations")
|
|
except Exception as e:
|
|
self._log(f"Could not connect to Redis: {e}. Continuing without caching.", level='warning')
|
|
self.redis_client = None
|
|
|
|
def _log(self, message: str, level: str = 'info'):
|
|
"""Log message via context.logger if available, otherwise use module logger"""
|
|
if self.context and hasattr(self.context, 'logger'):
|
|
log_func = getattr(self.context.logger, level, self.context.logger.info)
|
|
log_func(f"[EspoCRM] {message}")
|
|
else:
|
|
log_func = getattr(logger, level, logger.info)
|
|
log_func(f"[EspoCRM] {message}")
|
|
|
|
def _get_headers(self) -> Dict[str, str]:
|
|
"""Generate request headers with API key"""
|
|
return {
|
|
'X-Api-Key': self.api_key,
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
}
|
|
|
|
async def api_call(
|
|
self,
|
|
endpoint: str,
|
|
method: str = 'GET',
|
|
params: Optional[Dict] = None,
|
|
json_data: Optional[Dict] = None,
|
|
timeout_seconds: Optional[int] = None
|
|
) -> Any:
|
|
"""
|
|
Make an API call to EspoCRM.
|
|
|
|
Args:
|
|
endpoint: API endpoint (e.g., '/CBeteiligte/123' or '/CVmhErstgespraech')
|
|
method: HTTP method (GET, POST, PUT, DELETE)
|
|
params: Query parameters
|
|
json_data: JSON body for POST/PUT
|
|
timeout_seconds: Request timeout
|
|
|
|
Returns:
|
|
Parsed JSON response or None
|
|
|
|
Raises:
|
|
EspoCRMError: On API errors
|
|
"""
|
|
# Ensure endpoint starts with /
|
|
if not endpoint.startswith('/'):
|
|
endpoint = '/' + endpoint
|
|
|
|
url = self.api_base_url.rstrip('/') + endpoint
|
|
headers = self._get_headers()
|
|
effective_timeout = aiohttp.ClientTimeout(
|
|
total=timeout_seconds or self.api_timeout_seconds
|
|
)
|
|
|
|
self._log(f"API call: {method} {url}", level='debug')
|
|
if params:
|
|
self._log(f"Params: {params}", level='debug')
|
|
|
|
async with aiohttp.ClientSession(timeout=effective_timeout) as session:
|
|
try:
|
|
async with session.request(
|
|
method,
|
|
url,
|
|
headers=headers,
|
|
params=params,
|
|
json=json_data
|
|
) as response:
|
|
# Log response status
|
|
self._log(f"Response status: {response.status}", level='debug')
|
|
|
|
# Handle errors
|
|
if response.status == 401:
|
|
raise EspoCRMAuthError("Authentication failed - check API key")
|
|
elif response.status == 403:
|
|
raise EspoCRMError("Access forbidden")
|
|
elif response.status == 404:
|
|
raise EspoCRMError(f"Resource not found: {endpoint}")
|
|
elif response.status >= 400:
|
|
error_text = await response.text()
|
|
raise EspoCRMError(f"API error {response.status}: {error_text}")
|
|
|
|
# Parse response
|
|
if response.content_type == 'application/json':
|
|
result = await response.json()
|
|
self._log(f"Response received", level='debug')
|
|
return result
|
|
else:
|
|
# For DELETE or other non-JSON responses
|
|
return None
|
|
|
|
except aiohttp.ClientError as e:
|
|
self._log(f"API call failed: {e}", level='error')
|
|
raise EspoCRMError(f"Request failed: {e}") from e
|
|
|
|
async def get_entity(self, entity_type: str, entity_id: str) -> Dict[str, Any]:
|
|
"""
|
|
Get a single entity by ID.
|
|
|
|
Args:
|
|
entity_type: Entity type (e.g., 'CBeteiligte', 'CVmhErstgespraech')
|
|
entity_id: Entity ID
|
|
|
|
Returns:
|
|
Entity data as dict
|
|
"""
|
|
self._log(f"Getting {entity_type} with ID: {entity_id}")
|
|
return await self.api_call(f"/{entity_type}/{entity_id}", method='GET')
|
|
|
|
async def list_entities(
|
|
self,
|
|
entity_type: str,
|
|
where: Optional[List[Dict]] = None,
|
|
select: Optional[str] = None,
|
|
order_by: Optional[str] = None,
|
|
offset: int = 0,
|
|
max_size: int = 50
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
List entities with filtering and pagination.
|
|
|
|
Args:
|
|
entity_type: Entity type
|
|
where: Filter conditions (EspoCRM format)
|
|
select: Comma-separated field list
|
|
order_by: Sort field
|
|
offset: Pagination offset
|
|
max_size: Max results per page
|
|
|
|
Returns:
|
|
Dict with 'list' and 'total' keys
|
|
"""
|
|
params = {
|
|
'offset': offset,
|
|
'maxSize': max_size
|
|
}
|
|
|
|
if where:
|
|
import json
|
|
# EspoCRM expects JSON-encoded where clause
|
|
params['where'] = where if isinstance(where, str) else json.dumps(where)
|
|
if select:
|
|
params['select'] = select
|
|
if order_by:
|
|
params['orderBy'] = order_by
|
|
|
|
self._log(f"Listing {entity_type} entities")
|
|
return await self.api_call(f"/{entity_type}", method='GET', params=params)
|
|
|
|
async def create_entity(
|
|
self,
|
|
entity_type: str,
|
|
data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a new entity.
|
|
|
|
Args:
|
|
entity_type: Entity type
|
|
data: Entity data
|
|
|
|
Returns:
|
|
Created entity with ID
|
|
"""
|
|
self._log(f"Creating {entity_type} entity")
|
|
return await self.api_call(f"/{entity_type}", method='POST', json_data=data)
|
|
|
|
async def update_entity(
|
|
self,
|
|
entity_type: str,
|
|
entity_id: str,
|
|
data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Update an existing entity.
|
|
|
|
Args:
|
|
entity_type: Entity type
|
|
entity_id: Entity ID
|
|
data: Updated fields
|
|
|
|
Returns:
|
|
Updated entity
|
|
"""
|
|
self._log(f"Updating {entity_type} with ID: {entity_id}")
|
|
return await self.api_call(f"/{entity_type}/{entity_id}", method='PUT', json_data=data)
|
|
|
|
async def delete_entity(self, entity_type: str, entity_id: str) -> bool:
|
|
"""
|
|
Delete an entity.
|
|
|
|
Args:
|
|
entity_type: Entity type
|
|
entity_id: Entity ID
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
self._log(f"Deleting {entity_type} with ID: {entity_id}")
|
|
await self.api_call(f"/{entity_type}/{entity_id}", method='DELETE')
|
|
return True
|
|
|
|
async def search_entities(
|
|
self,
|
|
entity_type: str,
|
|
query: str,
|
|
fields: Optional[List[str]] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search entities by text query.
|
|
|
|
Args:
|
|
entity_type: Entity type
|
|
query: Search query
|
|
fields: Fields to search in
|
|
|
|
Returns:
|
|
List of matching entities
|
|
"""
|
|
where = [{
|
|
'type': 'textFilter',
|
|
'value': query
|
|
}]
|
|
|
|
result = await self.list_entities(entity_type, where=where)
|
|
return result.get('list', [])
|
|
|
|
async def upload_attachment(
|
|
self,
|
|
file_content: bytes,
|
|
filename: str,
|
|
parent_type: str,
|
|
parent_id: str,
|
|
field: str,
|
|
mime_type: str = 'application/octet-stream',
|
|
role: str = 'Attachment'
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Upload an attachment to EspoCRM.
|
|
|
|
Args:
|
|
file_content: File content as bytes
|
|
filename: Name of the file
|
|
parent_type: Parent entity type (e.g., 'Document')
|
|
parent_id: Parent entity ID
|
|
field: Field name for the attachment (e.g., 'preview')
|
|
mime_type: MIME type of the file
|
|
role: Attachment role (default: 'Attachment')
|
|
|
|
Returns:
|
|
Attachment entity data
|
|
"""
|
|
self._log(f"Uploading attachment: {filename} ({len(file_content)} bytes) to {parent_type}/{parent_id}/{field}")
|
|
|
|
url = self.api_base_url.rstrip('/') + '/Attachment'
|
|
headers = {
|
|
'X-Api-Key': self.api_key,
|
|
# Content-Type wird automatisch von aiohttp gesetzt für FormData
|
|
}
|
|
|
|
# Erstelle FormData
|
|
form_data = aiohttp.FormData()
|
|
form_data.add_field('file', file_content, filename=filename, content_type=mime_type)
|
|
form_data.add_field('parentType', parent_type)
|
|
form_data.add_field('parentId', parent_id)
|
|
form_data.add_field('field', field)
|
|
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}")
|
|
|
|
if response.status == 401:
|
|
raise EspoCRMAuthError("Authentication failed - check API key")
|
|
elif response.status == 403:
|
|
raise EspoCRMError("Access forbidden")
|
|
elif response.status == 404:
|
|
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
|
|
if response.content_type == 'application/json':
|
|
result = await response.json()
|
|
attachment_id = result.get('id')
|
|
self._log(f"✅ Attachment uploaded successfully: {attachment_id}")
|
|
return result
|
|
else:
|
|
response_text = await response.text()
|
|
self._log(f"⚠️ Non-JSON response: {response_text[:200]}", level='warn')
|
|
return {'success': True, 'response': response_text}
|
|
|
|
except aiohttp.ClientError as e:
|
|
self._log(f"Upload failed: {e}", level='error')
|
|
raise EspoCRMError(f"Upload request failed: {e}") from e
|
|
|
|
async def download_attachment(self, attachment_id: str) -> bytes:
|
|
"""
|
|
Download an attachment from EspoCRM.
|
|
|
|
Args:
|
|
attachment_id: Attachment ID
|
|
|
|
Returns:
|
|
File content as bytes
|
|
"""
|
|
self._log(f"Downloading attachment: {attachment_id}")
|
|
|
|
url = self.api_base_url.rstrip('/') + f'/Attachment/file/{attachment_id}'
|
|
headers = {
|
|
'X-Api-Key': self.api_key,
|
|
}
|
|
|
|
effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds)
|
|
|
|
async with aiohttp.ClientSession(timeout=effective_timeout) as session:
|
|
try:
|
|
async with session.get(url, headers=headers) as response:
|
|
if response.status == 401:
|
|
raise EspoCRMAuthError("Authentication failed - check API key")
|
|
elif response.status == 403:
|
|
raise EspoCRMError("Access forbidden")
|
|
elif response.status == 404:
|
|
raise EspoCRMError(f"Attachment not found: {attachment_id}")
|
|
elif response.status >= 400:
|
|
error_text = await response.text()
|
|
raise EspoCRMError(f"Download error {response.status}: {error_text}")
|
|
|
|
content = await response.read()
|
|
self._log(f"✅ Downloaded {len(content)} bytes")
|
|
return content
|
|
|
|
except aiohttp.ClientError as e:
|
|
self._log(f"Download failed: {e}", level='error')
|
|
raise EspoCRMError(f"Download request failed: {e}") from e
|