Compare commits

...

73 Commits

Author SHA1 Message Date
bsiggel
71f583481a fix: Remove deprecated AI Chat Completions and Models List API implementations 2026-03-19 23:10:00 +00:00
bsiggel
48d440a860 fix: Remove deprecated VMH xAI Chat Completions API implementation 2026-03-19 21:42:43 +00:00
bsiggel
c02a5d8823 fix: Update ExecModule exec path to use correct binary location 2026-03-19 21:23:42 +00:00
bsiggel
edae5f6081 fix: Update ExecModule configuration to use correct source directory for step scripts 2026-03-19 21:20:31 +00:00
bsiggel
8ce843415e feat: Enhance developer guide with updated platform evolution and workflow details 2026-03-19 20:56:32 +00:00
bsiggel
46085bd8dd update to iii 0.90 and change directory structure 2026-03-19 20:33:49 +00:00
bsiggel
2ac83df1e0 fix: Update default chat model to grok-4-1-fast-reasoning and enhance logging for LLM responses 2026-03-19 09:50:31 +00:00
bsiggel
7fffdb2660 fix: Simplify error logging in models list API handler 2026-03-19 09:48:57 +00:00
bsiggel
69f0c6a44d feat: Implement AI Chat Completions API with streaming support and models list endpoint
- Enhanced the AI Chat Completions API to support true streaming using async generators and proper SSE headers.
- Updated endpoint paths to align with OpenAI's API versioning.
- Improved logging for request details and error handling.
- Added a new AI Models List API to return available models compatible with chat completions.
- Refactored code for better readability and maintainability, including the extraction of common functionalities.
- Introduced a VMH-specific Chat Completions API with similar features and structure.
2026-03-18 21:30:59 +00:00
bsiggel
949a5fd69c feat: Implement AI Chat Completions API with support for file search, web search, and Aktenzeichen-based collection lookup 2026-03-18 18:22:04 +00:00
bsiggel
8e53fd6345 fix: Enhance tool binding in LangChainXAIService to support web search and update API handler for new parameters 2026-03-15 16:37:57 +00:00
bsiggel
59fdd7d9ec fix: Normalize MIME type for PDF uploads and update collection management endpoint to use vector store API 2026-03-15 16:34:13 +00:00
bsiggel
eaab14ae57 fix: Adjust multipart form to use raw UTF-8 encoding for filenames in file uploads 2026-03-14 23:00:49 +00:00
bsiggel
331d43390a fix: Import unquote for URL decoding in AI Knowledge synchronization utilities 2026-03-14 22:50:59 +00:00
bsiggel
18f2ff775e fix: URL-decode filenames in document synchronization to handle special characters 2026-03-14 22:49:07 +00:00
bsiggel
c032e24d7a fix: Update default model name to 'grok-4-1-fast-reasoning' in xAI Chat Completions API 2026-03-14 08:39:50 +00:00
bsiggel
4a5065aea4 feat: Add Aktenzeichen utility functions and LangChain xAI service integration
- Implemented utility functions for extracting, validating, and normalizing Aktenzeichen in 'aktenzeichen_utils.py'.
- Created LangChainXAIService for integrating LangChain ChatXAI with file search capabilities in 'langchain_xai_service.py'.
- Developed VMH xAI Chat Completions API to handle OpenAI-compatible requests with support for Aktenzeichen detection and file search in 'xai_chat_completion_api_step.py'.
2026-03-13 10:10:33 +00:00
bsiggel
bb13d59ddb fix: Improve orphan detection and Blake3 hash verification in document synchronization 2026-03-13 08:40:20 +00:00
bsiggel
b0fceef4e2 fix: Update sync mode logging to clarify Blake3 hash verification status 2026-03-12 23:09:21 +00:00
bsiggel
e727582584 fix: Update JunctionData URL construction to use API Gateway instead of direct EspoCRM endpoint 2026-03-12 23:07:33 +00:00
bsiggel
2292fd4762 feat: Enhance document synchronization logic to continue syncing after collection activation 2026-03-12 23:06:40 +00:00
bsiggel
9ada48d8c8 fix: Update collection ID retrieval logic and simplify error logging in AI Knowledge sync event handler 2026-03-12 23:04:01 +00:00
bsiggel
9a3e01d447 fix: Correct logging method from warning to warn for lock acquisition in AI Knowledge sync handler 2026-03-12 23:00:08 +00:00
bsiggel
e945333c1a feat: Update activation status references to 'aktivierungsstatus' for consistency across AI Knowledge sync utilities 2026-03-12 22:53:47 +00:00
bsiggel
6f7f847939 feat: Enhance AI Knowledge Update webhook handler to validate payload structure and handle empty lists 2026-03-12 22:51:44 +00:00
bsiggel
46c0bbf381 feat: Refactor AI Knowledge sync processes to remove full sync parameter and ensure Blake3 verification is always performed 2026-03-12 22:41:19 +00:00
bsiggel
8f1533337c feat: Enhance AI Knowledge sync process with full sync mode and attachment handling 2026-03-12 22:35:48 +00:00
bsiggel
6bf2343a12 feat: Enhance document synchronization by integrating CAIKnowledge handling and improving error logging 2026-03-12 22:30:11 +00:00
bsiggel
8ed7cca432 feat: Add logging utility for calendar sync operations and enhance error handling 2026-03-12 19:26:04 +00:00
bsiggel
9bbfa61b3b feat: Implement AI Knowledge Sync Utilities and Event Handlers
- Added AIKnowledgeActivationStatus and AIKnowledgeSyncStatus enums to models.py for managing activation and sync states.
- Introduced AIKnowledgeSync class in aiknowledge_sync_utils.py for synchronizing CAIKnowledge entities with XAI Collections, including collection lifecycle management, document synchronization, and metadata updates.
- Created a daily cron job (aiknowledge_full_sync_cron_step.py) to perform a full sync of CAIKnowledge entities.
- Developed an event handler (aiknowledge_sync_event_step.py) to synchronize CAIKnowledge entities with XAI Collections triggered by webhooks and cron jobs.
- Implemented a webhook handler (aiknowledge_update_api_step.py) to receive updates from EspoCRM for CAIKnowledge entities and enqueue sync events.
- Enhanced xai_service.py with methods for collection management, document listing, and metadata updates.
2026-03-11 21:14:52 +00:00
bsiggel
a5a122b688 refactor(logging): enhance error handling and resource management in rate limiting and sync operations 2026-03-08 22:47:05 +00:00
bsiggel
6c3cf3ca91 refactor(logging): remove unused logger instances and enhance error logging in webhook steps 2026-03-08 22:21:08 +00:00
bsiggel
1c765d1eec refactor(logging): standardize status code handling and enhance logging in webhook and cron handlers 2026-03-08 22:09:22 +00:00
bsiggel
a0cf845877 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.
2026-03-08 21:50:34 +00:00
bsiggel
f392ec0f06 refactor(typing): update handler signatures to use Dict and Any for improved type hinting 2026-03-08 21:24:12 +00:00
bsiggel
2532bd89ee refactor(logging): standardize logging approach across services and steps 2026-03-08 21:20:49 +00:00
bsiggel
2e449d2928 docs: enhance error handling and locking strategies in document synchronization 2026-03-08 20:58:58 +00:00
bsiggel
fd0196ec31 docs: add guidelines for Steps vs. Utils architecture and decision matrix 2026-03-08 20:30:33 +00:00
bsiggel
d71b5665b6 docs: update Design Principles section with enhanced lock strategy and event handling guidelines 2026-03-08 20:28:57 +00:00
bsiggel
d69801ed97 docs: expand Design Principles section with detailed guidelines and examples for event handling and locking strategies 2026-03-08 20:18:18 +00:00
bsiggel
6e2303c5eb feat(docs): add Design Principles section with Event Storm and Bidirectional Reference patterns 2026-03-08 20:09:32 +00:00
bsiggel
93d4d89531 feat(document-sync): update xaiSyncStatus handling and logging for document synchronization 2026-03-08 19:21:17 +00:00
bsiggel
4ed752b19e feat(document-sync): enhance metadata update to reset file status after preview generation 2026-03-08 18:59:56 +00:00
bsiggel
ba657ecd3b docs: update troubleshooting section with service restart instructions and log checking commands 2026-03-08 18:49:45 +00:00
bsiggel
9e7e163933 docs: add production and manual start instructions for system services 2026-03-08 18:49:08 +00:00
bsiggel
82b48eee8e fix(api-steps): update response status field in document create, delete, and update handlers 2026-03-08 18:48:56 +00:00
bsiggel
7fd6eed86d feat(sync-utils): add logging delegation method to BaseSyncUtils 2026-03-08 18:38:48 +00:00
bsiggel
91ae2947e5 feat(xai-service): implement xAI Files & Collections service for document synchronization 2026-03-08 18:31:29 +00:00
bsiggel
6f7d62293e feat(espocrm): add logging method to EspoCRMAPI for improved message handling
fix(calendar_sync): correct cron expression for calendar sync job
2026-03-08 17:58:10 +00:00
bsiggel
d7b2b5543f feat(espocrm): add caching for entity definitions and implement related entity listing 2026-03-08 17:51:36 +00:00
bsiggel
a53051ea8e feat(api-client): implement session management for AdvowareAPI and EspoCRMAPI 2026-03-03 17:24:35 +00:00
bsiggel
69a48f5f9a Implement central configuration, custom exceptions, logging utilities, Pydantic models, and Redis client for BitByLaw integration
- Added `config.py` for centralized configuration management including Sync, API, Advoware, EspoCRM, Redis, Logging, Calendar Sync, and Feature Flags.
- Created `exceptions.py` with a hierarchy of custom exceptions for integration errors, API errors, sync errors, and Redis errors.
- Developed `logging_utils.py` for a unified logging wrapper supporting structured logging and performance tracking.
- Defined Pydantic models in `models.py` for data validation of Advoware and EspoCRM entities, including sync operation models.
- Introduced `redis_client.py` for a centralized Redis client factory with connection pooling, automatic reconnection, and health checks.
2026-03-03 17:18:49 +00:00
bsiggel
bcb6454b2a Add comprehensive test scripts for thumbnail generation and xAI collections API
- Implemented `test_thumbnail_generation.py` to validate the complete flow of document thumbnail generation in EspoCRM, including document creation, file upload, webhook triggering, and preview verification.
- Created `test_xai_collections_api.py` to test critical operations of the xAI Collections API, covering file uploads, collection CRUD operations, document management, and response validation.
- Both scripts include detailed logging for success and error states, ensuring robust testing and easier debugging.
2026-03-03 17:03:08 +00:00
bsiggel
c45bfb7233 Enhance EspoCRM API and Webhook Handling
- 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.
2026-03-03 16:53:55 +00:00
bsiggel
0e521f22f8 feat(preview-generation): implement thumbnail generation for documents; add preview upload to EspoCRM 2026-03-03 09:28:49 +00:00
bsiggel
70265c9adf feat(document-sync): enhance DocumentSync with file status checks and hash-based change detection; add thumbnail generation and metadata update methods 2026-03-03 09:15:02 +00:00
bsiggel
ee9aab049f feat(document-sync): add Document Sync Utilities and VMH Document Sync Handler for xAI integration 2026-03-03 06:55:54 +00:00
bsiggel
cb0e170ee9 feat(webhooks): update EspoCRM webhook configurations to use VMH naming and endpoints 2026-03-02 17:34:17 +00:00
bsiggel
bc917bd885 feat(webhooks): add EspoCRM Document Delete and Update webhook handlers with detailed logging
- Implemented document delete webhook handler to process delete notifications from EspoCRM.
- Added detailed logging for incoming payloads, including headers and entity information.
- Extracted and emitted document IDs for further processing.
- Implemented document update webhook handler to handle update notifications from EspoCRM.
- Enhanced logging for update payloads, including changed fields and metadata.
- Both handlers emit events for queue processing to facilitate further actions.

test: add comprehensive xAI Collections API test suite

- Created a test suite for xAI Collections API covering critical operations.
- Included tests for file uploads, CRUD operations on collections and documents, and response structures.
- Verified shared file behavior across multiple collections.
- Implemented cleanup procedures to remove test resources after execution.
2026-03-02 17:16:07 +00:00
bsiggel
0282149613 Fix cron expression in Calendar Sync Cron Job to trigger at second 0 and update entity retrieval methods in Beteiligte Sync steps for consistency 2026-03-02 09:36:09 +00:00
bsiggel
0740952063 Fix cron expression in Calendar Sync Cron Job to trigger every 15 minutes 2026-03-01 23:47:36 +00:00
bsiggel
f1ac5ffc7e Update cron description in Calendar Sync Cron Job for clarity 2026-03-01 23:46:51 +00:00
bsiggel
5c204ba16c Add api_call method to AdvowareService for delegating API calls 2026-03-01 23:40:50 +00:00
bsiggel
721339ca9b Update flow names for Advoware and VMH steps to improve clarity and organization 2026-03-01 23:28:29 +00:00
bsiggel
dc586385df Refactor logging in log_operation to support context-based logging and fallback to standard logger 2026-03-01 23:15:14 +00:00
bsiggel
0b8da01b71 Update README and add detailed documentation for Advoware integration and calendar sync 2026-03-01 23:03:01 +00:00
bsiggel
52356e634e 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.
2026-03-01 22:55:17 +00:00
bsiggel
17f908d036 Add requests dependency to project
- Included 'requests' in the dependencies list of uv.lock.
- Specified minimum version of 'requests' as 2.32.0 in requires-dist.
2026-03-01 22:39:33 +00:00
bsiggel
014947e9e0 Migrate VMH Integration - Phase 3: Core sync handlers & utilities
- Added 5 core service modules:
  * services/notification_utils.py: NotificationManager for manual actions (412 lines)
  * services/advoware_service.py: Extended Advoware operations wrapper
  * services/espocrm_mapper.py: BeteiligteMapper for data transformation (198 lines)
  * services/bankverbindungen_mapper.py: BankverbindungenMapper (174 lines)
  * services/beteiligte_sync_utils.py: BeteiligteSync with complex logic (663 lines)
    - Distributed locking via Redis + syncStatus
    - rowId-based change detection (primary) + timestamp fallback
    - Retry with exponential backoff (1min, 5min, 15min, 1h, 4h)
    - Conflict resolution (EspoCRM wins)
    - Soft-delete handling & round-trip validation

- Added 2 event handler steps:
  * steps/vmh/beteiligte_sync_event_step.py: Handles vmh.beteiligte.* queue events
    - CREATE: New Beteiligte → POST to Advoware
    - UPDATE: Bi-directional sync with conflict resolution
    - DELETE: Not yet implemented
    - SYNC_CHECK: Periodic sync check via cron
  * steps/vmh/bankverbindungen_sync_event_step.py: Handles vmh.bankverbindungen.* events
    - CREATE: New Bankverbindung → POST to Advoware
    - UPDATE/DELETE: Send notification (API limitation - no PUT/DELETE support)

- Added pytz dependency to pyproject.toml (required for timezone handling)

System Status:
- 27 steps registered (14 VMH-specific)
- 8 queue subscriptions active (4 Beteiligte + 4 Bankverbindungen)
- All webhook endpoints operational and emitting queue events
- Sync handlers ready for processing

Note: Kommunikation sync (kommunikation_sync_utils.py) not yet migrated.
Complex bi-directional sync for Telefon/Email/Fax will be added in Phase 4.

Updated MIGRATION_STATUS.md
2026-03-01 22:19:36 +00:00
bsiggel
0216c4c3ae Migrate VMH Integration - Phase 2: Webhook endpoints
- Added services/espocrm.py: EspoCRM API client with Redis support
- Added 6 VMH webhook steps for EspoCRM integration:

  Beteiligte webhooks:
  - POST /vmh/webhook/beteiligte/create
  - POST /vmh/webhook/beteiligte/update
  - POST /vmh/webhook/beteiligte/delete

  Bankverbindungen webhooks:
  - POST /vmh/webhook/bankverbindungen/create
  - POST /vmh/webhook/bankverbindungen/update
  - POST /vmh/webhook/bankverbindungen/delete

All webhook endpoints receive batch/single entity notifications from
EspoCRM and emit queue events for downstream processing.

Note: Complex sync handlers (event processors) not yet migrated -
they require additional utility modules (beteiligte_sync_utils.py,
mappers, notification_utils) which will be migrated in Phase 3.

Updated MIGRATION_STATUS.md with Phase 2 completion.
2026-03-01 21:52:19 +00:00
bsiggel
164c90c89d Update migration status: Phase 1 complete 2026-03-01 21:48:04 +00:00
bsiggel
a24282c346 Migrate Advoware Proxy steps from old-motia
- Added services/advoware.py: Advoware API client with Redis token caching
- Added advoware_proxy steps (GET, POST, PUT, DELETE)
- Updated pyproject.toml: added aiohttp, redis, python-dotenv
- Updated iii-config.yaml: fixed ExecModule command to use full path
- Created MIGRATION_STATUS.md: documentation of migration progress

All 4 proxy endpoints registered successfully:
- GET /advoware/proxy
- POST /advoware/proxy
- PUT /advoware/proxy
- DELETE /advoware/proxy
2026-03-01 21:47:43 +00:00
bsiggel
a35eab562f Add old-motia directory to .gitignore for migration reference 2026-03-01 21:40:45 +00:00
75 changed files with 20158 additions and 396 deletions

3
.gitignore vendored
View File

@@ -32,3 +32,6 @@ Thumbs.db
# Env files with secrets
.env
.env.local
# Old Motia installation (for migration reference)
old-motia/

263
README.md
View File

@@ -1,34 +1,207 @@
# Motia III - BitByLaw Backend
# bitbylaw - Motia III Integration Platform
**Migration Complete: 21/21 Steps (100%)**
Event-driven Integration zwischen Advoware, EspoCRM und Google Calendar mit **Motia III v1.0-RC** (Pure Python).
## Quick Start
```bash
cd /opt/motia-iii/bitbylaw
# Start iii Engine
/opt/bin/iii -c iii-config.yaml
# Start iii Console (Web UI) - separate terminal
/opt/bin/iii-console --enable-flow --host 0.0.0.0 --port 3113 \
--engine-host localhost --engine-port 3111 --ws-port 3114
```
Open browser: `http://localhost:3113/`
## Migration Status
This project has been **fully migrated** from old Motia v0.17 (Node.js + Python) to **Motia III v1.0-RC** (Pure Python).
See:
- [MIGRATION_STATUS.md](MIGRATION_STATUS.md) - Progress overview
- [MIGRATION_COMPLETE_ANALYSIS.md](MIGRATION_COMPLETE_ANALYSIS.md) - Complete analysis
- [docs/INDEX.md](docs/INDEX.md) - Complete documentation index
## Komponenten
1. **Advoware API Proxy** (4 Steps) - REST-API-Proxy mit HMAC-512 Auth
- GET, POST, PUT, DELETE proxies
- [Details](steps/advoware_proxy/README.md)
2. **Calendar Sync** (4 Steps) - Bidirektionale Synchronisation Advoware ↔ Google
- Cron-triggered (every 15 min)
- Manual API trigger
- Per-employee sync
- [Details](steps/advoware_cal_sync/README.md)
3. **VMH Integration** (9 Steps) - EspoCRM Webhook-Receiver & Sync
- Beteiligte sync (bidirectional)
- Bankverbindungen sync
- Webhook handlers (create/update/delete)
- [Details](steps/vmh/README.md)
**Total: 17 Steps registered**
## Architektur
```
┌─────────────┐ ┌──────────┐ ┌────────────┐
│ EspoCRM │────▶│ Webhooks │────▶│ Redis │
│ (VMH) │ │ (HTTP) │ │ Locking │
└─────────────┘ └──────────┘ └────────────┘
┌─────────────┐ ┌──────────┐ │
│ Clients │────▶│ Proxy │────▶ │
│ │ │ API API │ │
└─────────────┘ └──────────┘ ▼
┌────────────┐
Cron (15min) │ Motia │
│ │ Steps │
└──────────────────────────▶│ (Python) │
└────────────┘
┌────────────┐
│ Advoware │
│ Google │
│ Calendar │
└────────────┘
```
## API Endpoints
**Advoware Proxy:**
- `GET/POST/PUT/DELETE /advoware/proxy?endpoint=...`
**Calendar Sync:**
- `POST /advoware/calendar/sync` - Manual trigger
```bash
# Sync single employee
curl -X POST "http://localhost:3111/advoware/calendar/sync" \
-H "Content-Type: application/json" \
-d '{"kuerzel": "PB"}'
# Sync all employees
curl -X POST "http://localhost:3111/advoware/calendar/sync" \
-H "Content-Type: application/json" \
-d '{"kuerzel": "ALL"}'
```
**VMH Webhooks:**
- `POST /vmh/webhook/beteiligte/create`
- `POST /vmh/webhook/beteiligte/update`
- `POST /vmh/webhook/beteiligte/delete`
- `POST /vmh/webhook/bankverbindungen/create`
- `POST /vmh/webhook/bankverbindungen/update`
- `POST /vmh/webhook/bankverbindungen/delete`
## Configuration
Environment variables loaded from systemd service or `.env` file:
```bash
# Advoware API
ADVOWARE_API_BASE_URL=https://www2.advo-net.net:90/
ADVOWARE_PRODUCT_ID=64
ADVOWARE_APP_ID=your_app_id
ADVOWARE_API_KEY=your_base64_hmac_key
ADVOWARE_KANZLEI=your_kanzlei
ADVOWARE_DATABASE=your_database
ADVOWARE_USER=api_user
ADVOWARE_ROLE=2
ADVOWARE_PASSWORD=your_password
ADVOWARE_TOKEN_LIFETIME_MINUTES=55
ADVOWARE_API_TIMEOUT_SECONDS=30
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB_CALENDAR_SYNC=1
REDIS_TIMEOUT_SECONDS=5
# Google Calendar
GOOGLE_CALENDAR_SERVICE_ACCOUNT_PATH=/path/to/service-account.json
# PostgreSQL
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=bitbylaw
POSTGRES_PASSWORD=your_password
POSTGRES_DATABASE=bitbylaw
```
See [docs/INDEX.md](docs/INDEX.md) for complete configuration guide.
Motia Backend-Anwendung powered by the iii engine.
## Project Structure
```
bitbylaw/
├── steps/ # Motia Steps (Business Logic)
├── iii-config.yaml # iii Engine Configuration
├── pyproject.toml # Python Dependencies
└── .venv/ # Python Virtual Environment
```
├── docs/ # Documentation
## Services
## Services & Ports
- **Motia API**: Port 3111 (https://api-motia.bitbylaw.com)
- **iii Console**: Port 3113 (https://motia.bitbylaw.com)
- **iii Engine (API)**: Port 3111 (https://api-motia.bitbylaw.com)
- **iii Console (Web UI)**: Port 3113 (https://motia.bitbylaw.com)
- **Streams/WebSocket**: Port 3114
- **Redis**: Port 6379 (localhost)
- **PostgreSQL**: Port 5432 (localhost)eps)
│ ├── advoware_cal_sync/ # Calendar Sync (4 steps)
│ └── vmh/ # VMH Integration (9 steps)
├── services/ # Shared Services
│ ├── advoware_service.py # Advoware API Client
│ ├── espocrm.py # EspoCRM API Client
│ └── ... # Other services
├── iii-config.yaml # iii Engine Configuration
├── pyproject.toml # Python Dependencies (uv)
├── MIGRATION_STATUS.md # Migration progress
├── MIGRATION_COMPLETE_ANALYSIS.md # Migration analysis
└── .venv/ # Python Virtual Environment
```
## Systemd Services
- `motia.service` - Backend Engine
- `iii-console.service` - Observability Dashboard
## Systemd Services
- `motia.service` - iii Engine (Backend)
- `iii-console.service` - iii Console (Observability Dashboard)
### Service Management
```bash
# Status
systemctl status motia.service
systemctl status iii-console.service
# Restart
## Development
```bash
# Install dependencies
cd /opt/motia-iii/bitbylaw
uv sync
# Start iii Engine (development)
iii -c iii-config.yaml
# Start iii Console (separate terminal)
iii-console --enable-flow --host 0.0.0.0 --port 3113 \
--engine-host localhost --engine-port 3111 --ws-port 3114
# Test step import
uv run python -c "from steps.advoware_proxy import advoware_api_proxy_get_step"
# Check registered steps
curl http://localhost:3111/_console/functions
# Start locally
iii -c iii-config.yaml
```
@@ -37,10 +210,70 @@ iii -c iii-config.yaml
Services run automatically via systemd on boot.
```bash
# Restart services
systemctl restart motia.service iii-console.service
## Technology Stack
- **Framework**: Motia III v1.0-RC (Pure Python, iii Engine)
- **Language**: Python 3.13
- **Package Manager**: uv
- **Data Store**: Redis (Caching, Locking), PostgreSQL (Sync State)
- **External APIs**:
- Advoware REST API (HMAC-512 auth)
- Google Calendar API (Service Account)
- EspoCRM API (X-Api-Key auth)
## Documentation
### Getting Started
- [Documentation Index](docs/INDEX.md) - Complete index
- [Migration Status](MIGRATION_STATUS.md) - 100% complete
- [Migration Analysis](MIGRATION_COMPLETE_ANALYSIS.md) - Complete analysis
### Technical Details
- [Architecture](docs/ARCHITECTURE.md) - System design (Motia III)
- [Advoware Proxy](steps/advoware_proxy/README.md) - API Proxy details
- [Calendar Sync](steps/advoware_cal_sync/README.md) - Sync logic
- [VMH Integration](steps/vmh/README.md) - Webhook handlers
### Migration
- [Migration Guide](../MIGRATION_GUIDE.md) - Old Motia → Motia III patterns
## Testing
```bash
# Test HTTP endpoints
curl http://localhost:3111/advoware/proxy?endpoint=employees
# Trigger calendar sync manually
curl -X POST "http://localhost:3111/advoware/calendar/sync" \
-H "Content-Type: application/json" \
-d '{"kuerzel": "ALL"}'
# Check registered functions
curl http://localhost:3111/_console/functions | grep "Calendar"
# View logs in Console
open http://localhost:3113/
```
## Production
Services run automatically via systemd on boot.
**Deployed on**: motia.bitbylaw.com
**Deployment Date**: März 2026
```bash
# Restart production services
sudo systemctl restart motia.service iii-console.service
# View production logs
journalctl -u motia.service -f
journalctl -u iii-console.service -f
# Check service status
systemctl status motia.service iii-console.service
```
# View logs
journalctl -u motia.service -f
journalctl -u iii-console.service -f

382
REFACTORING_SUMMARY.md Normal file
View File

@@ -0,0 +1,382 @@
# Code Refactoring - Verbesserungen Übersicht
Datum: 3. März 2026
## Zusammenfassung
Umfassendes Refactoring zur Verbesserung von Robustheit, Eleganz und Effizienz des BitByLaw Integration Codes.
## Implementierte Verbesserungen
### 1. ✅ Custom Exception Classes ([services/exceptions.py](services/exceptions.py))
**Problem:** Zu generisches Exception Handling mit `except Exception`
**Lösung:** Hierarchische Exception-Struktur:
```python
from services.exceptions import (
AdvowareAPIError,
AdvowareAuthError,
AdvowareTimeoutError,
EspoCRMAPIError,
EspoCRMAuthError,
RetryableError,
NonRetryableError,
LockAcquisitionError,
ValidationError
)
# Verwendung:
try:
result = await advoware.api_call(...)
except AdvowareTimeoutError:
# Spezifisch für Timeouts
raise RetryableError()
except AdvowareAuthError:
# Auth-Fehler nicht retryable
raise
except AdvowareAPIError as e:
# Andere API-Fehler
if is_retryable(e):
# Retry logic
```
**Vorteile:**
- Präzise Fehlerbehandlung
- Besseres Error Tracking
- Automatische Retry-Klassifizierung mit `is_retryable()`
---
### 2. ✅ Redis Client Factory ([services/redis_client.py](services/redis_client.py))
**Problem:** Duplizierte Redis-Initialisierung in 4+ Dateien
**Lösung:** Zentralisierte Redis Client Factory mit Singleton Pattern:
```python
from services.redis_client import get_redis_client, is_redis_available
# Strict mode: Exception bei Fehler
redis_client = get_redis_client(strict=True)
# Optional mode: None bei Fehler (für optionale Features)
redis_client = get_redis_client(strict=False)
# Health Check
if is_redis_available():
# Redis verfügbar
```
**Vorteile:**
- DRY (Don't Repeat Yourself)
- Connection Pooling
- Zentrale Konfiguration
- Health Checks
---
### 3. ✅ Pydantic Models für Validation ([services/models.py](services/models.py))
**Problem:** Keine Datenvalidierung, unsichere Typen
**Lösung:** Pydantic Models mit automatischer Validierung:
```python
from services.models import (
AdvowareBeteiligteCreate,
EspoCRMBeteiligteCreate,
validate_beteiligte_advoware
)
# Automatische Validierung:
try:
validated = AdvowareBeteiligteCreate.model_validate(data)
except ValidationError as e:
# Handle validation errors
# Helper:
validated = validate_beteiligte_advoware(data)
```
**Features:**
- Type Safety
- Automatische Validierung (Geburtsdatum, Name, etc.)
- Enums für Status/Rechtsformen
- Field Validators
---
### 4. ✅ Zentrale Konfiguration ([services/config.py](services/config.py))
**Problem:** Magic Numbers und Strings überall im Code
**Lösung:** Zentrale Config mit Dataclasses:
```python
from services.config import (
SYNC_CONFIG,
API_CONFIG,
ADVOWARE_CONFIG,
ESPOCRM_CONFIG,
FEATURE_FLAGS,
get_retry_delay_seconds,
get_lock_key
)
# Verwendung:
max_retries = SYNC_CONFIG.max_retries # 5
lock_ttl = SYNC_CONFIG.lock_ttl_seconds # 900
backoff = SYNC_CONFIG.retry_backoff_minutes # [1, 5, 15, 60, 240]
# Helper Functions:
lock_key = get_lock_key('cbeteiligte', entity_id)
retry_delay = get_retry_delay_seconds(attempt=2) # 15 * 60 seconds
```
**Konfigurationsbereiche:**
- `SYNC_CONFIG` - Retry, Locking, Change Detection
- `API_CONFIG` - Timeouts, Rate Limiting
- `ADVOWARE_CONFIG` - Token, Auth, Read-only Fields
- `ESPOCRM_CONFIG` - Pagination, Notifications
- `FEATURE_FLAGS` - Feature Toggles
---
### 5. ✅ Konsistentes Logging ([services/logging_utils.py](services/logging_utils.py))
**Problem:** Inkonsistentes Logging (3 verschiedene Patterns)
**Lösung:** Unified Logger mit Context-Support:
```python
from services.logging_utils import get_logger, get_service_logger
# Service Logger:
logger = get_service_logger('advoware', context)
logger.info("Message", entity_id="123")
# Mit Context Manager für Timing:
with logger.operation('sync_entity', entity_id='123'):
# Do work
pass # Automatisches Timing und Error Logging
# API Call Tracking:
with logger.api_call('/api/v1/Beteiligte', method='POST'):
result = await api.post(...)
```
**Features:**
- Motia FlowContext Support
- Structured Logging
- Automatisches Performance Tracking
- Context Fields
---
### 6. ✅ Spezifische Exceptions in Services
**Aktualisierte Services:**
- [advoware.py](services/advoware.py) - AdvowareAPIError, AdvowareAuthError, AdvowareTimeoutError
- [espocrm.py](services/espocrm.py) - EspoCRMAPIError, EspoCRMAuthError, EspoCRMTimeoutError
- [sync_utils_base.py](services/sync_utils_base.py) - LockAcquisitionError
- [beteiligte_sync_utils.py](services/beteiligte_sync_utils.py) - SyncError
**Beispiel:**
```python
# Vorher:
except Exception as e:
logger.error(f"Error: {e}")
# Nachher:
except AdvowareTimeoutError:
raise RetryableError("Request timed out")
except AdvowareAuthError:
raise # Nicht retryable
except AdvowareAPIError as e:
if is_retryable(e):
# Retry
```
---
### 7. ✅ Type Hints ergänzt
**Verbesserte Type Hints in:**
- Service-Methoden (advoware.py, espocrm.py)
- Mapper-Funktionen (espocrm_mapper.py)
- Utility-Klassen (sync_utils_base.py, beteiligte_sync_utils.py)
- Step Handler
**Beispiel:**
```python
# Vorher:
async def handler(event_data, ctx):
...
# Nachher:
async def handler(
event_data: Dict[str, Any],
ctx: FlowContext[Any]
) -> Optional[Dict[str, Any]]:
...
```
---
## Migration Guide
### Für bestehenden Code
1. **Exception Handling aktualisieren:**
```python
# Alt:
try:
result = await api.call()
except Exception as e:
logger.error(f"Error: {e}")
# Neu:
try:
result = await api.call()
except AdvowareTimeoutError:
# Spezifisch behandeln
raise RetryableError()
except AdvowareAPIError as e:
logger.error(f"API Error: {e}")
if is_retryable(e):
# Retry
```
2. **Redis initialisieren:**
```python
# Alt:
redis_client = redis.Redis(host=..., port=...)
# Neu:
from services.redis_client import get_redis_client
redis_client = get_redis_client(strict=False)
```
3. **Konstanten verwenden:**
```python
# Alt:
MAX_RETRIES = 5
LOCK_TTL = 900
# Neu:
from services.config import SYNC_CONFIG
max_retries = SYNC_CONFIG.max_retries
lock_ttl = SYNC_CONFIG.lock_ttl_seconds
```
4. **Logging standardisieren:**
```python
# Alt:
logger = logging.getLogger(__name__)
logger.info("Message")
# Neu:
from services.logging_utils import get_service_logger
logger = get_service_logger('my_service', context)
logger.info("Message", entity_id="123")
```
---
## Performance-Verbesserungen
- ✅ Redis Connection Pooling (max 50 Connections)
- ✅ Token Caching optimiert
- ✅ Bessere Error Classification (weniger unnötige Retries)
- ⚠️ Noch TODO: Batch Operations für parallele Syncs
---
## Feature Flags
Neue Features können über `FEATURE_FLAGS` gesteuert werden:
```python
from services.config import FEATURE_FLAGS
# Aktivieren/Deaktivieren:
FEATURE_FLAGS.strict_validation = True # Pydantic Validation
FEATURE_FLAGS.kommunikation_sync_enabled = False # Noch in Entwicklung
FEATURE_FLAGS.parallel_sync_enabled = False # Experimentell
```
---
## Testing
**Unit Tests sollten nun leichter sein:**
```python
# Mock Redis:
from services.redis_client import RedisClientFactory
RedisClientFactory._instance = mock_redis
# Mock Exceptions:
from services.exceptions import AdvowareAPIError
raise AdvowareAPIError("Test error", status_code=500)
# Validate Models:
from services.models import validate_beteiligte_advoware
with pytest.raises(ValidationError):
validate_beteiligte_advoware(invalid_data)
```
---
## Nächste Schritte
1. **Unit Tests schreiben** (min. 60% Coverage)
- Exception Handling Tests
- Mapper Tests mit Pydantic
- Redis Factory Tests
2. **Batch Operations** implementieren
- Parallele API-Calls
- Bulk Updates
3. **Monitoring** verbessern
- Performance Metrics aus Logger nutzen
- Redis Health Checks
4. **Dokumentation** erweitern
- API-Docs generieren (Sphinx)
- Error Handling Guide
---
## Breakfree Changes
⚠️ **Minimale Breaking Changes:**
1. Import-Pfade haben sich geändert:
- `AdvowareTokenError``AdvowareAuthError`
- `EspoCRMError``EspoCRMAPIError`
2. Redis wird jetzt über Factory bezogen:
- Statt direktem `redis.Redis()``get_redis_client()`
**Migration ist einfach:** Imports aktualisieren, Code läuft sonst identisch.
---
## Autoren
- Code Refactoring: GitHub Copilot
- Review: BitByLaw Team
- Datum: 3. März 2026
---
## Fragen?
Bei Fragen zum Refactoring siehe:
- [services/README.md](services/README.md) - Service-Layer Dokumentation
- [exceptions.py](services/exceptions.py) - Exception Hierarchie
- [config.py](services/config.py) - Alle Konfigurationsoptionen

599
docs/AI_KNOWLEDGE_SYNC.md Normal file
View File

@@ -0,0 +1,599 @@
# AI Knowledge Collection Sync - Dokumentation
**Version**: 1.0
**Datum**: 11. März 2026
**Status**: ✅ Implementiert
---
## Überblick
Synchronisiert EspoCRM `CAIKnowledge` Entities mit XAI Collections für semantische Dokumentensuche. Unterstützt vollständigen Collection-Lifecycle, BLAKE3-basierte Integritätsprüfung und robustes Hash-basiertes Change Detection.
## Features
**Collection Lifecycle Management**
- NEW → Collection erstellen in XAI
- ACTIVE → Automatischer Sync der Dokumente
- PAUSED → Sync pausiert, Collection bleibt
- DEACTIVATED → Collection aus XAI löschen
**Dual-Hash Change Detection**
- EspoCRM Hash (MD5/SHA256) für lokale Änderungserkennung
- XAI BLAKE3 Hash für Remote-Integritätsverifikation
- Metadata-Hash für Beschreibungs-Änderungen
**Robustheit**
- BLAKE3 Verification nach jedem Upload
- Metadata-Only Updates via PATCH
- Orphan Detection & Cleanup
- Distributed Locking (Redis)
- Daily Full Sync (02:00 Uhr nachts)
**Fehlerbehandlung**
- Unsupported MIME Types → Status "unsupported"
- Transient Errors → Retry mit Exponential Backoff
- Partial Failures toleriert
---
## Architektur
```
┌─────────────────────────────────────────────────────────────────┐
│ EspoCRM CAIKnowledge │
│ ├─ activationStatus: new/active/paused/deactivated │
│ ├─ syncStatus: unclean/pending_sync/synced/failed │
│ └─ datenbankId: XAI Collection ID │
└─────────────────────────────────────────────────────────────────┘
↓ Webhook
┌─────────────────────────────────────────────────────────────────┐
│ Motia Webhook Handler │
│ → POST /vmh/webhook/aiknowledge/update │
└─────────────────────────────────────────────────────────────────┘
↓ Emit Event
┌─────────────────────────────────────────────────────────────────┐
│ Queue: aiknowledge.sync │
└─────────────────────────────────────────────────────────────────┘
↓ Lock: aiknowledge:{id}
┌─────────────────────────────────────────────────────────────────┐
│ Sync Handler │
│ ├─ Check activationStatus │
│ ├─ Manage Collection Lifecycle │
│ ├─ Sync Documents (with BLAKE3 verification) │
│ └─ Update Statuses │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ XAI Collections API │
│ └─ Collections with embedded documents │
└─────────────────────────────────────────────────────────────────┘
```
---
## EspoCRM Konfiguration
### 1. Entity: CAIKnowledge
**Felder:**
| Feld | Typ | Beschreibung | Werte |
|------|-----|--------------|-------|
| `name` | varchar(255) | Name der Knowledge Base | - |
| `datenbankId` | varchar(255) | XAI Collection ID | Automatisch gefüllt |
| `activationStatus` | enum | Lifecycle-Status | new, active, paused, deactivated |
| `syncStatus` | enum | Sync-Status | unclean, pending_sync, synced, failed |
| `lastSync` | datetime | Letzter erfolgreicher Sync | ISO 8601 |
| `syncError` | text | Fehlermeldung bei Failure | Max 2000 Zeichen |
**Enum-Definitionen:**
```json
{
"activationStatus": {
"type": "enum",
"options": ["new", "active", "paused", "deactivated"],
"default": "new"
},
"syncStatus": {
"type": "enum",
"options": ["unclean", "pending_sync", "synced", "failed"],
"default": "unclean"
}
}
```
### 2. Junction: CAIKnowledgeCDokumente
**additionalColumns:**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `aiDocumentId` | varchar(255) | XAI file_id |
| `syncstatus` | enum | Per-Document Sync-Status |
| `syncedHash` | varchar(64) | MD5/SHA256 von EspoCRM |
| `xaiBlake3Hash` | varchar(128) | BLAKE3 Hash von XAI |
| `syncedMetadataHash` | varchar(64) | Hash der Metadaten |
| `lastSync` | datetime | Letzter Sync dieses Dokuments |
**Enum-Definition:**
```json
{
"syncstatus": {
"type": "enum",
"options": ["new", "unclean", "synced", "failed", "unsupported"]
}
}
```
### 3. Webhooks
**Webhook 1: CREATE**
```json
{
"event": "CAIKnowledge.afterSave",
"url": "https://your-motia-domain.com/vmh/webhook/aiknowledge/update",
"method": "POST",
"payload": "{\"entity_id\": \"{$id}\", \"entity_type\": \"CAIKnowledge\", \"action\": \"create\"}",
"condition": "entity.isNew()"
}
```
**Webhook 2: UPDATE**
```json
{
"event": "CAIKnowledge.afterSave",
"url": "https://your-motia-domain.com/vmh/webhook/aiknowledge/update",
"method": "POST",
"payload": "{\"entity_id\": \"{$id}\", \"entity_type\": \"CAIKnowledge\", \"action\": \"update\"}",
"condition": "!entity.isNew()"
}
```
**Webhook 3: DELETE (Optional)**
```json
{
"event": "CAIKnowledge.afterRemove",
"url": "https://your-motia-domain.com/vmh/webhook/aiknowledge/delete",
"method": "POST",
"payload": "{\"entity_id\": \"{$id}\", \"entity_type\": \"CAIKnowledge\", \"action\": \"delete\"}"
}
```
**Empfehlung**: Nur CREATE + UPDATE verwenden. DELETE über `activationStatus="deactivated"` steuern.
### 4. Hooks (EspoCRM Backend)
**Hook 1: Document Link → syncStatus auf "unclean"**
```php
// Hooks/Custom/CAIKnowledge/AfterRelateLinkMultiple.php
namespace Espo\Custom\Hooks\CAIKnowledge;
class AfterRelateLinkMultiple extends \Espo\Core\Hooks\Base
{
public function afterRelateLinkMultiple($entity, $options, $data)
{
if ($data['link'] === 'dokumentes') {
// Mark as unclean when documents linked
$entity->set('syncStatus', 'unclean');
$this->getEntityManager()->saveEntity($entity);
}
}
}
```
**Hook 2: Document Change → Junction auf "unclean"**
```php
// Hooks/Custom/CDokumente/AfterSave.php
namespace Espo\Custom\Hooks\CDokumente;
class AfterSave extends \Espo\Core\Hooks\Base
{
public function afterSave($entity, $options)
{
if ($entity->isAttributeChanged('description') ||
$entity->isAttributeChanged('md5') ||
$entity->isAttributeChanged('sha256')) {
// Mark all junction entries as unclean
$this->updateJunctionStatuses($entity->id, 'unclean');
// Mark all related CAIKnowledge as unclean
$this->markRelatedKnowledgeUnclean($entity->id);
}
}
}
```
---
## Environment Variables
```bash
# XAI API Keys (erforderlich)
XAI_API_KEY=your_xai_api_key_here
XAI_MANAGEMENT_KEY=your_xai_management_key_here
# Redis (für Locking)
REDIS_HOST=localhost
REDIS_PORT=6379
# EspoCRM
ESPOCRM_API_BASE_URL=https://crm.bitbylaw.com/api/v1
ESPOCRM_API_KEY=your_espocrm_api_key
```
---
## Workflows
### Workflow 1: Neue Knowledge Base erstellen
```
1. User erstellt CAIKnowledge in EspoCRM
└─ activationStatus: "new" (default)
2. Webhook CREATE gefeuert
└─ Event: aiknowledge.sync
3. Sync Handler:
└─ activationStatus="new" → Collection erstellen in XAI
└─ Update EspoCRM:
├─ datenbankId = collection_id
├─ activationStatus = "active"
└─ syncStatus = "unclean"
4. Nächster Webhook (UPDATE):
└─ activationStatus="active" → Dokumente syncen
```
### Workflow 2: Dokumente hinzufügen
```
1. User verknüpft Dokumente mit CAIKnowledge
└─ EspoCRM Hook setzt syncStatus = "unclean"
2. Webhook UPDATE gefeuert
└─ Event: aiknowledge.sync
3. Sync Handler:
└─ Für jedes Junction-Entry:
├─ Check: MIME Type supported?
├─ Check: Hash changed?
├─ Download von EspoCRM
├─ Upload zu XAI mit Metadata
├─ Verify Upload (BLAKE3)
└─ Update Junction: syncstatus="synced"
4. Update CAIKnowledge:
└─ syncStatus = "synced"
└─ lastSync = now()
```
### Workflow 3: Metadata-Änderung
```
1. User ändert Document.description in EspoCRM
└─ EspoCRM Hook setzt Junction syncstatus = "unclean"
└─ EspoCRM Hook setzt CAIKnowledge syncStatus = "unclean"
2. Webhook UPDATE gefeuert
3. Sync Handler:
└─ Berechne Metadata-Hash
└─ Hash unterschiedlich? → PATCH zu XAI
└─ Falls PATCH fehlschlägt → Fallback: Re-upload
└─ Update Junction: syncedMetadataHash
```
### Workflow 4: Knowledge Base deaktivieren
```
1. User setzt activationStatus = "deactivated"
2. Webhook UPDATE gefeuert
3. Sync Handler:
└─ Collection aus XAI löschen
└─ Alle Junction Entries zurücksetzen:
├─ syncstatus = "new"
└─ aiDocumentId = NULL
└─ CAIKnowledge bleibt in EspoCRM (mit datenbankId)
```
### Workflow 5: Daily Full Sync
```
Cron: Täglich um 02:00 Uhr
1. Lade alle CAIKnowledge mit:
└─ activationStatus = "active"
└─ syncStatus IN ("unclean", "failed")
2. Für jedes:
└─ Emit: aiknowledge.sync Event
3. Queue verarbeitet alle sequenziell
└─ Fängt verpasste Webhooks ab
```
---
## Monitoring & Troubleshooting
### Logs prüfen
```bash
# Motia Service Logs
sudo journalctl -u motia-iii -f | grep -i "ai knowledge"
# Letzte 100 Sync-Events
sudo journalctl -u motia-iii -n 100 | grep "AI KNOWLEDGE SYNC"
# Fehler der letzten 24 Stunden
sudo journalctl -u motia-iii --since "24 hours ago" | grep "❌"
```
### EspoCRM Status prüfen
```sql
-- Alle Knowledge Bases mit Status
SELECT
id,
name,
activation_status,
sync_status,
last_sync,
sync_error
FROM c_ai_knowledge
WHERE activation_status = 'active';
-- Junction Entries mit Sync-Problemen
SELECT
j.id,
k.name AS knowledge_name,
d.name AS document_name,
j.syncstatus,
j.last_sync
FROM c_ai_knowledge_c_dokumente j
JOIN c_ai_knowledge k ON j.c_ai_knowledge_id = k.id
JOIN c_dokumente d ON j.c_dokumente_id = d.id
WHERE j.syncstatus IN ('failed', 'unsupported');
```
### Häufige Probleme
#### Problem: "Lock busy for aiknowledge:xyz"
**Ursache**: Vorheriger Sync noch aktiv oder abgestürzt
**Lösung**:
```bash
# Redis lock manuell freigeben
redis-cli
> DEL sync_lock:aiknowledge:xyz
```
#### Problem: "Unsupported MIME type"
**Ursache**: Document hat MIME Type, den XAI nicht unterstützt
**Lösung**:
- Dokument konvertieren (z.B. RTF → PDF)
- Oder: Akzeptieren (bleibt mit Status "unsupported")
#### Problem: "Upload verification failed"
**Ursache**: XAI liefert kein BLAKE3 Hash oder Hash-Mismatch
**Lösung**:
1. Prüfe XAI API Dokumentation (Hash-Format geändert?)
2. Falls temporär: Retry läuft automatisch
3. Falls persistent: XAI Support kontaktieren
#### Problem: "Collection not found"
**Ursache**: Collection wurde manuell in XAI gelöscht
**Lösung**: Automatisch gelöst - Sync erstellt neue Collection
---
## API Endpoints
### Webhook Endpoint
```http
POST /vmh/webhook/aiknowledge/update
Content-Type: application/json
{
"entity_id": "kb-123",
"entity_type": "CAIKnowledge",
"action": "update"
}
```
**Response:**
```json
{
"success": true,
"knowledge_id": "kb-123"
}
```
---
## Performance
### Typische Sync-Zeiten
| Szenario | Zeit | Notizen |
|----------|------|---------|
| Collection erstellen | < 1s | Nur API Call |
| 1 Dokument (1 MB) | 2-4s | Upload + Verify |
| 10 Dokumente (10 MB) | 20-40s | Sequenziell |
| 100 Dokumente (100 MB) | 3-6 min | Lock TTL: 30 min |
| Metadata-only Update | < 1s | Nur PATCH |
| Orphan Cleanup | 1-3s | Pro 10 Dokumente |
### Lock TTLs
- **AIKnowledge Sync**: 30 Minuten (1800 Sekunden)
- **Redis Lock**: Same as above
- **Auto-Release**: Bei Timeout (TTL expired)
### Rate Limits
**XAI API:**
- Files Upload: ~100 requests/minute
- Management API: ~1000 requests/minute
**Strategie bei Rate Limit (429)**:
- Exponential Backoff: 2s, 4s, 8s, 16s, 32s
- Respect `Retry-After` Header
- Max 5 Retries
---
## XAI Collections Metadata
### Document Metadata Fields
Werden für jedes Dokument in XAI gespeichert:
```json
{
"fields": {
"document_name": "Vertrag.pdf",
"description": "Mietvertrag Mustermann",
"created_at": "2024-01-01T00:00:00Z",
"modified_at": "2026-03-10T15:30:00Z",
"espocrm_id": "dok-123"
}
}
```
**inject_into_chunk**: `true` für `document_name` und `description`
→ Verbessert semantische Suche
### Collection Metadata
```json
{
"metadata": {
"espocrm_entity_type": "CAIKnowledge",
"espocrm_entity_id": "kb-123",
"created_at": "2026-03-11T10:00:00Z"
}
}
```
---
## Testing
### Manueller Test
```bash
# 1. Erstelle CAIKnowledge in EspoCRM
# 2. Prüfe Logs
sudo journalctl -u motia-iii -f
# 3. Prüfe Redis Lock
redis-cli
> KEYS sync_lock:aiknowledge:*
# 4. Prüfe XAI Collection
curl -H "Authorization: Bearer $XAI_MANAGEMENT_KEY" \
https://management-api.x.ai/v1/collections
```
### Integration Test
```python
# tests/test_aiknowledge_sync.py
async def test_full_sync_workflow():
"""Test complete sync workflow"""
# 1. Create CAIKnowledge with status "new"
knowledge = await espocrm.create_entity('CAIKnowledge', {
'name': 'Test KB',
'activationStatus': 'new'
})
# 2. Trigger webhook
await trigger_webhook(knowledge['id'])
# 3. Wait for sync
await asyncio.sleep(5)
# 4. Check collection created
knowledge = await espocrm.get_entity('CAIKnowledge', knowledge['id'])
assert knowledge['datenbankId'] is not None
assert knowledge['activationStatus'] == 'active'
# 5. Link document
await espocrm.link_entities('CAIKnowledge', knowledge['id'], 'CDokumente', doc_id)
# 6. Trigger webhook again
await trigger_webhook(knowledge['id'])
await asyncio.sleep(10)
# 7. Check junction synced
junction = await espocrm.get_junction_entries(
'CAIKnowledgeCDokumente',
'cAIKnowledgeId',
knowledge['id']
)
assert junction[0]['syncstatus'] == 'synced'
assert junction[0]['xaiBlake3Hash'] is not None
```
---
## Maintenance
### Wöchentliche Checks
- [ ] Prüfe failed Syncs in EspoCRM
- [ ] Prüfe Redis Memory Usage
- [ ] Prüfe XAI Storage Usage
- [ ] Review Logs für Patterns
### Monatliche Tasks
- [ ] Cleanup alte syncError Messages
- [ ] Verify XAI Collection Integrity
- [ ] Review Performance Metrics
- [ ] Update MIME Type Support List
---
## Support
**Bei Problemen:**
1. **Logs prüfen**: `journalctl -u motia-iii -f`
2. **EspoCRM Status prüfen**: SQL Queries (siehe oben)
3. **Redis Locks prüfen**: `redis-cli KEYS sync_lock:*`
4. **XAI API Status**: https://status.x.ai
**Kontakt:**
- Team: BitByLaw Development
- Motia Docs: `/opt/motia-iii/bitbylaw/docs/INDEX.md`
---
**Version History:**
- **1.0** (11.03.2026) - Initial Release
- Collection Lifecycle Management
- BLAKE3 Hash Verification
- Daily Full Sync
- Metadata Change Detection

View File

@@ -0,0 +1,160 @@
# Document Sync mit xAI Collections - Implementierungs-Status
## ✅ Implementiert
### 1. Webhook Endpunkte
- **POST** `/vmh/webhook/document/create`
- **POST** `/vmh/webhook/document/update`
- **POST** `/vmh/webhook/document/delete`
### 2. Event Handler (`document_sync_event_step.py`)
- Queue Topics: `vmh.document.{create|update|delete}`
- Redis Distributed Locking
- Vollständiges Document Loading von EspoCRM
### 3. Sync Utilities (`document_sync_utils.py`)
- **✅ Datei-Status Prüfung**: "Neu", "Geändert" → xAI-Sync erforderlich
- **✅ Hash-basierte Change Detection**: MD5/SHA Vergleich für Updates
- **✅ Related Entities Discovery**: Many-to-Many Attachments durchsuchen
- **✅ Collection Requirements**: Automatische Ermittlung welche Collections nötig sind
## ⏳ In Arbeit
### 4. Preview-Generierung (`generate_thumbnail()`)
**✅ Implementiert** - Bereit zum Installieren der Dependencies
**Konfiguration:**
- **Feld in EspoCRM**: `preview` (Attachment)
- **Format**: **WebP** (bessere Kompression als PNG/JPEG)
- **Größe**: **600x800px** (behält Aspect Ratio)
- **Qualität**: 85% (guter Kompromiss zwischen Qualität und Dateigröße)
**Unterstützte Formate:**
- ✅ PDF: Erste Seite als Preview
- ✅ DOCX/DOC: Konvertierung zu PDF, dann erste Seite
- ✅ Images (JPG, PNG, etc.): Resize auf Preview-Größe
- ❌ Andere: Kein Preview (TODO: Generic File-Icons)
**Benötigte Dependencies:**
```bash
# Python Packages
pip install pdf2image Pillow docx2pdf
# System Dependencies (Ubuntu/Debian)
apt-get install poppler-utils libreoffice
```
**Installation:**
```bash
cd /opt/motia-iii/bitbylaw
/opt/bin/uv pip install pdf2image Pillow docx2pdf
# System packages
sudo apt-get update
sudo apt-get install -y poppler-utils libreoffice
```
## ❌ Noch nicht implementiert
### 5. xAI Service (`xai_service.py`)
**Anforderungen:**
- File Upload zu xAI (basierend auf `test_xai_collections_api.py`)
- Add File zu Collections
- Remove File von Collections
- File Download von EspoCRM
**Referenz-Code vorhanden:**
- `/opt/motia-iii/bitbylaw/test_xai_collections_api.py` (630 Zeilen, alle xAI Operations getestet)
**Implementierungs-Plan:**
```python
class XAIService:
def __init__(self, context=None):
self.management_key = os.getenv('XAI_MANAGEMENT_KEY')
self.api_key = os.getenv('XAI_API_KEY')
self.context = context
async def upload_file(self, file_content: bytes, filename: str) -> str:
"""Upload File zu xAI → returns file_id"""
# Multipart/form-data upload
# POST https://api.x.ai/v1/files
pass
async def add_to_collection(self, collection_id: str, file_id: str):
"""Add File zu Collection"""
# POST https://management-api.x.ai/v1/collections/{collection_id}/documents/{file_id}
pass
async def remove_from_collection(self, collection_id: str, file_id: str):
"""Remove File von Collection"""
# DELETE https://management-api.x.ai/v1/collections/{collection_id}/documents/{file_id}
pass
async def download_from_espocrm(self, attachment_id: str) -> bytes:
"""Download File von EspoCRM Attachment"""
# GET https://crm.bitbylaw.com/api/v1/Attachment/file/{attachment_id}
pass
```
## 📋 Integration Checklist
### Vollständiger Upload-Flow:
1. ✅ Webhook empfangen → Event emittieren
2. ✅ Event Handler: Lock acquire
3. ✅ Document laden von EspoCRM
4. ✅ Entscheidung: Sync nötig? (Datei-Status, Hash-Check, Collections)
5. ⏳ Download File von EspoCRM
6. ⏳ Hash berechnen (MD5/SHA)
7. ⏳ Thumbnail generieren
8. ❌ Upload zu xAI (falls neu oder Hash changed)
9. ❌ Add zu Collections
10. ⏳ Update EspoCRM Metadaten (xaiFileId, xaiCollections, xaiSyncedHash, thumbnail)
11. ✅ Lock release
### Datei-Stati in EspoCRM:
- **"Neu"**: Komplett neue Datei → xAI Upload + Collection Add
- **"Geändert"**: File-Inhalt geändert → xAI Re-Upload + Collection Update
- **"Gesynct"**: Erfolgreich gesynct, keine Änderungen
- **"Fehler"**: Sync fehlgeschlagen (mit Error-Message)
### EspoCRM Custom Fields:
**Erforderlich für Document Entity:**
- `dateiStatus` (Enum): "Neu", "Geändert", "Gesynct", "Fehler"
- `md5` (String): MD5 Hash des Files
- `sha` (String): SHA Hash des Files
- `xaiFileId` (String): xAI File ID
- `xaiCollections` (Array): JSON Array von Collection IDs
- `xaiSyncedHash` (String): Hash beim letzten erfolgreichen Sync
- `xaiSyncStatus` (Enum): "syncing", "synced", "failed"
- `xaiSyncError` (Text): Fehlermeldung bei Sync-Fehler
- **`preview` (Attachment)**: Vorschaubild im WebP-Format (600x800px)
## 🚀 Nächste Schritte
**Priorität 1: xAI Service**
- Code aus `test_xai_collections_api.py` extrahieren
- In `services/xai_service.py` übertragen
- EspoCRM Download-Funktion implementieren
**Priorität 2: Thumbnail-Generator**
- Dependencies installieren
- PDF-Thumbnail implementieren
- EspoCRM Upload-Methode erweitern
**Priorität 3: Integration testen**
- Document in EspoCRM anlegen
- Datei-Status auf "Neu" setzen
- Webhook triggern
- Logs analysieren
## 📚 Referenzen
- **xAI API Tests**: `/opt/motia-iii/bitbylaw/test_xai_collections_api.py`
- **EspoCRM API**: `services/espocrm.py`
- **Beteiligte Sync** (Referenz-Implementierung): `steps/vmh/beteiligte_sync_event_step.py`

1961
docs/INDEX.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,6 @@ modules:
- class: modules::shell::ExecModule
config:
watch:
- steps/**/*.py
- src/steps/**/*.py
exec:
- uv run motia run --dir steps
- /usr/local/bin/uv run python -m motia.cli run --dir src/steps

View File

@@ -9,5 +9,17 @@ dependencies = [
"motia[otel]==1.0.0rc24",
"iii-sdk==0.2.0",
"pydantic>=2.0",
"aiohttp>=3.10.0",
"redis>=5.2.0",
"python-dotenv>=1.0.0",
"pytz>=2025.2",
"requests>=2.32.0",
"asyncpg>=0.29.0", # PostgreSQL async driver for calendar sync
"google-api-python-client>=2.100.0", # Google Calendar API
"google-auth>=2.23.0", # Google OAuth2
"backoff>=2.2.1", # Retry/backoff decorator
"langchain>=0.3.0", # LangChain framework
"langchain-xai>=0.2.0", # xAI integration for LangChain
"langchain-core>=0.3.0", # LangChain core
]

13
service-account.json Normal file
View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "bitbylaw-475919",
"private_key_id": "1b83e57147852a870e34df41fbcfec281dccaf33",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDCYbRo/sgisHAp\noMCZ4c1ijpJ6igaKv6b9DmB8nH+DZeiJiGIYpOSVvpbTGLX4C4bg8lzrP/cYMhwm\ni2LATOEi8fMQX4/9v4yxr6lc+341/6bZ5Zp0qepqhJazFjJOhty2KovqJebiAJXE\n4AtDV4kgNJHZKAi9bSx3N6ltTP/qUS5BRuws2nrC3iaI+pHWIjG4vhH7Sjp/Ga86\nI05IcLGDG/SUX3oqqwpjACNGI+9/8hCfeqyMUjPhn82xivGzw+z4nC2iwcnRgsHB\nFxr5kpamJMmNOiWtZaYOBJzIa5EL4cu7VCMVt80VbA/Ezci+WvpJJ7nn2gS0KJGP\nXcdXGsrNAgMBAAECggEAByQfGaBf6o5VgIysUWWZ7bNT2aFKfjxuPmu8dbeAzmtR\nsK52/srIBGGnxCzrOn1J65Jb1t9XDOeCYJQhq548wyjlB3FLUUTWlAyNCffJ+jfg\nga7NZC3TM9iXz2UoXKQtuu+Ii7CaxoDDqnjvUP5dRkvyyRSPGvkbd2xvEq86fk7Z\nbeqZoW3pmIqr4IMNRRRnXCq6bwCYg4paQZiyTnrX8JnnIq03hsSxcFEQsk78bsaF\nVBF76wtsjTFlcLTqzVzJDep15BXN/KP12bSgzlTC2P1lB97+XmrI+6/Cj6cv3Vda\nISgmEBZbeph+rBzJ4M+5R4wM9ygP2aGhsORTmzFMrQKBgQDu91WL8IXS9ZPUO26e\nutorkTlIAb1Jih8N5s4q07XF8SzScDw/E7ZdXAvylfHpnxLv1B6zi8r/MGAamHS9\n0iFzvrxE81s2h8U2bIhyADhkjGLQFXPK+cW62ym3ZATGExxWoQL9jpczsgNOv78d\n384w1bf4MBudlHhXj7Wt+dpK0wKBgQDQPMpm7l7lSBHRPOV2+Th0G1ExJAHhqrTC\nbx/X8e6WABsJ+o/N9ttrPsHlZ4lzhuNw5v0hYrPV0YrXRg98QqYUbKTrAHRIq4gL\nhnrmVDywTUPeU4PEoW0of8rQeQUzVCdw3dKhKTs6H9LN7VUy3mEu5L78mdPYQL2E\nwwNPZOHP3wKBgGg8dRFcslMqEfiyj/cnFEGK0EyrjZDFcfRTaDzgKlsUb3O/x1fQ\nVmz02LVRWLuKSu1YPqgc40hbJqCTPeELBtKBMYh2CqSHpqutvfrUQ8UAQ532rZKt\nTuXJ8bFwLHDmJydWhoJpr2S6Up0IIOp8FGnS37Of8HvVJoUzR5GC+ghHAoGAZLrj\nVcM9GEAyjjqc7V5FFrUYI2M9ncILynoitz0KonjOm5ce9QzSuyASfzwEW2QGpVi3\nXez2/RltxhDX8M30tLCRXjRMC9Md7iVRUhWxfb8Cc4uGlBlaSlr26r1/7IJqycgj\n2V2ujsFSIdcKfZ7g9+QjFuH6fgNjKdODyGYObZUCgYBTePTscIghcfq5aJiEcNR0\nGaCaVnXLMbWIXMNYBrDotD30j4J7TyPny2b3xv5tfSud/c+gWzzJy3QLQY56ab1s\n89gG0KHSNvsMK+pJsB+3b9C+pFMRIlzJPXS++tIyBSf3gwV5PInytkE1ZgB+EpEQ\nAmAF5lUQ4XydjX3MTT1S4A==\n-----END PRIVATE KEY-----\n",
"client_email": "bitbylaw-advoware-snyc@bitbylaw-475919.iam.gserviceaccount.com",
"client_id": "104069107394434106127",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/bitbylaw-advoware-snyc%40bitbylaw-475919.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

1
services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Services package"""

263
services/adressen_mapper.py Normal file
View File

@@ -0,0 +1,263 @@
"""
Adressen Mapper: EspoCRM CAdressen ↔ Advoware Adressen
Transformiert Adressen zwischen den beiden Systemen.
Basierend auf ADRESSEN_SYNC_ANALYSE.md Abschnitt 12.
"""
from typing import Dict, Any, Optional
from datetime import datetime
class AdressenMapper:
"""Mapper für CAdressen (EspoCRM) ↔ Adressen (Advoware)"""
@staticmethod
def map_cadressen_to_advoware_create(espo_addr: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert EspoCRM CAdressen → Advoware Adressen Format (CREATE/POST)
Für CREATE werden ALLE 11 Felder gemappt (inkl. READ-ONLY bei PUT).
Args:
espo_addr: CAdressen Entity von EspoCRM
Returns:
Dict für Advoware POST /api/v1/advonet/Beteiligte/{betnr}/Adressen
"""
logger.debug(f"Mapping EspoCRM → Advoware (CREATE): {espo_addr.get('id')}")
# Formatiere Anschrift (mehrzeilig)
anschrift = AdressenMapper._format_anschrift(espo_addr)
advo_data = {
# R/W Felder (via PUT änderbar)
'strasse': espo_addr.get('adresseStreet') or '',
'plz': espo_addr.get('adressePostalCode') or '',
'ort': espo_addr.get('adresseCity') or '',
'anschrift': anschrift,
# READ-ONLY Felder (nur bei CREATE!)
'land': espo_addr.get('adresseCountry') or 'DE',
'postfach': espo_addr.get('postfach'),
'postfachPLZ': espo_addr.get('postfachPLZ'),
'standardAnschrift': bool(espo_addr.get('isPrimary', False)),
'bemerkung': f"EspoCRM-ID: {espo_addr['id']}", # WICHTIG für Matching!
'gueltigVon': AdressenMapper._format_datetime(espo_addr.get('validFrom')),
'gueltigBis': AdressenMapper._format_datetime(espo_addr.get('validUntil'))
}
return advo_data
@staticmethod
def map_cadressen_to_advoware_update(espo_addr: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert EspoCRM CAdressen → Advoware Adressen Format (UPDATE/PUT)
Für UPDATE werden NUR die 4 R/W Felder gemappt!
Alle anderen Änderungen müssen über Notifications gehandelt werden.
Args:
espo_addr: CAdressen Entity von EspoCRM
Returns:
Dict für Advoware PUT /api/v1/advonet/Beteiligte/{betnr}/Adressen/{index}
"""
logger.debug(f"Mapping EspoCRM → Advoware (UPDATE): {espo_addr.get('id')}")
# NUR R/W Felder!
advo_data = {
'strasse': espo_addr.get('adresseStreet') or '',
'plz': espo_addr.get('adressePostalCode') or '',
'ort': espo_addr.get('adresseCity') or '',
'anschrift': AdressenMapper._format_anschrift(espo_addr)
}
return advo_data
@staticmethod
def map_advoware_to_cadressen(advo_addr: Dict[str, Any],
beteiligte_id: str,
existing_espo_addr: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Transformiert Advoware Adressen → EspoCRM CAdressen Format
Args:
advo_addr: Adresse von Advoware GET
beteiligte_id: EspoCRM CBeteiligte ID (für Relation)
existing_espo_addr: Existierende EspoCRM Entity (für Update)
Returns:
Dict für EspoCRM API
"""
logger.debug(f"Mapping Advoware → EspoCRM: Index {advo_addr.get('reihenfolgeIndex')}")
espo_data = {
# Core Adressfelder
'adresseStreet': advo_addr.get('strasse'),
'adressePostalCode': advo_addr.get('plz'),
'adresseCity': advo_addr.get('ort'),
'adresseCountry': advo_addr.get('land') or 'DE',
# Zusatzfelder
'postfach': advo_addr.get('postfach'),
'postfachPLZ': advo_addr.get('postfachPLZ'),
'description': advo_addr.get('bemerkung'),
# Status-Felder
'isPrimary': bool(advo_addr.get('standardAnschrift', False)),
'validFrom': advo_addr.get('gueltigVon'),
'validUntil': advo_addr.get('gueltigBis'),
# Sync-Felder
'advowareRowId': advo_addr.get('rowId'),
'advowareLastSync': datetime.now().isoformat(),
'syncStatus': 'synced',
# Relation
'beteiligteId': beteiligte_id
}
# Preserve existing fields when updating
if existing_espo_addr:
espo_data['id'] = existing_espo_addr['id']
# Keep existing isActive if not changed
if 'isActive' in existing_espo_addr:
espo_data['isActive'] = existing_espo_addr['isActive']
else:
# New address
espo_data['isActive'] = True
return espo_data
@staticmethod
def detect_readonly_changes(espo_addr: Dict[str, Any],
advo_addr: Dict[str, Any]) -> list[Dict[str, Any]]:
"""
Erkenne Änderungen an READ-ONLY Feldern (nicht via PUT änderbar)
Args:
espo_addr: EspoCRM CAdressen Entity
advo_addr: Advoware Adresse
Returns:
Liste von Änderungen mit Feldnamen und Werten
"""
changes = []
# Mapping: EspoCRM-Feld → (Advoware-Feld, Label)
readonly_mappings = {
'adresseCountry': ('land', 'Land'),
'postfach': ('postfach', 'Postfach'),
'postfachPLZ': ('postfachPLZ', 'Postfach PLZ'),
'isPrimary': ('standardAnschrift', 'Hauptadresse'),
'validFrom': ('gueltigVon', 'Gültig von'),
'validUntil': ('gueltigBis', 'Gültig bis')
}
for espo_field, (advo_field, label) in readonly_mappings.items():
espo_value = espo_addr.get(espo_field)
advo_value = advo_addr.get(advo_field)
# Normalisiere Werte für Vergleich
if espo_field == 'isPrimary':
espo_value = bool(espo_value)
advo_value = bool(advo_value)
elif espo_field in ['validFrom', 'validUntil']:
# Datetime-Vergleich (nur Datum)
espo_value = AdressenMapper._normalize_date(espo_value)
advo_value = AdressenMapper._normalize_date(advo_value)
# Vergleiche
if espo_value != advo_value:
changes.append({
'field': label,
'espoField': espo_field,
'advoField': advo_field,
'espoCRM_value': espo_value,
'advoware_value': advo_value
})
return changes
@staticmethod
def _format_anschrift(espo_addr: Dict[str, Any]) -> str:
"""
Formatiert mehrzeilige Anschrift für Advoware
Format:
{Firmenname oder Name}
{Strasse}
{PLZ} {Ort}
"""
parts = []
# Zeile 1: Name
if espo_addr.get('firmenname'):
parts.append(espo_addr['firmenname'])
elif espo_addr.get('firstName') or espo_addr.get('lastName'):
name = f"{espo_addr.get('firstName', '')} {espo_addr.get('lastName', '')}".strip()
if name:
parts.append(name)
# Zeile 2: Straße
if espo_addr.get('adresseStreet'):
parts.append(espo_addr['adresseStreet'])
# Zeile 3: PLZ + Ort
plz = espo_addr.get('adressePostalCode', '').strip()
ort = espo_addr.get('adresseCity', '').strip()
if plz or ort:
parts.append(f"{plz} {ort}".strip())
return '\n'.join(parts)
@staticmethod
def _format_datetime(dt: Any) -> Optional[str]:
"""
Formatiert Datetime für Advoware API (ISO 8601)
Args:
dt: datetime object, ISO string, oder None
Returns:
ISO 8601 string oder None
"""
if not dt:
return None
if isinstance(dt, str):
# Bereits String - prüfe ob gültig
try:
datetime.fromisoformat(dt.replace('Z', '+00:00'))
return dt
except:
return None
if isinstance(dt, datetime):
return dt.isoformat()
return None
@staticmethod
def _normalize_date(dt: Any) -> Optional[str]:
"""
Normalisiert Datum für Vergleich (nur Datum, keine Zeit)
Returns:
YYYY-MM-DD string oder None
"""
if not dt:
return None
if isinstance(dt, str):
try:
dt_obj = datetime.fromisoformat(dt.replace('Z', '+00:00'))
return dt_obj.strftime('%Y-%m-%d')
except:
return None
if isinstance(dt, datetime):
return dt.strftime('%Y-%m-%d')
return None

694
services/adressen_sync.py Normal file
View File

@@ -0,0 +1,694 @@
"""
Adressen Synchronization: EspoCRM ↔ Advoware
Synchronisiert CAdressen zwischen EspoCRM und Advoware.
Basierend auf ADRESSEN_SYNC_ANALYSE.md Abschnitt 12.
SYNC-STRATEGIE:
- CREATE: Vollautomatisch (alle 11 Felder)
- UPDATE: Nur R/W Felder (strasse, plz, ort, anschrift)
- DELETE: Nur via Notification (kein API-DELETE verfügbar)
- READ-ONLY Änderungen: Nur via Notification
KONFLIKT-BEHANDLUNG (wie bei Beteiligten):
- rowId-basierte Änderungserkennung (Advoware rowId ändert sich bei jedem PUT)
- Timestamp-Vergleich für EspoCRM (modifiedAt vs advowareLastSync)
- Bei Konflikt (beide geändert): EspoCRM GEWINNT IMMER!
- Notification bei Konflikt mit Details
"""
from typing import Dict, Any, Optional, List
from datetime import datetime
import logging
from services.advoware import AdvowareAPI
from services.espocrm import EspoCRMAPI
from services.adressen_mapper import AdressenMapper
from services.notification_utils import NotificationManager
class AdressenSync:
"""Sync-Klasse für Adressen zwischen EspoCRM und Advoware"""
def __init__(self, context=None):
"""
Initialize AdressenSync
Args:
context: Application context mit logger
"""
self.context = context
self.advo = AdvowareAPI(context=context)
self.espo = EspoCRMAPI(context=context)
self.mapper = AdressenMapper()
self.notification_manager = NotificationManager(espocrm_api=self.espo, context=context)
# ========================================================================
# KONFLIKT-ERKENNUNG
# ========================================================================
def compare_addresses(self, espo_addr: Dict[str, Any], advo_addr: Dict[str, Any]) -> str:
"""
Vergleicht Änderungen zwischen EspoCRM und Advoware Adresse
Nutzt die gleiche Strategie wie bei Beteiligten:
- PRIMÄR: rowId-Vergleich (Advoware rowId ändert sich bei jedem PUT)
- FALLBACK: Keine Änderung wenn rowId gleich
Args:
espo_addr: EspoCRM CAdressen Entity
advo_addr: Advoware Adresse
Returns:
"espocrm_newer": EspoCRM wurde seit letztem Sync geändert
"advoware_newer": Advoware wurde seit letztem Sync geändert
"conflict": Beide wurden seit letztem Sync geändert (EspoCRM gewinnt!)
"no_change": Keine Änderungen
"""
espo_rowid = espo_addr.get('advowareRowId')
advo_rowid = advo_addr.get('rowId')
last_sync = espo_addr.get('advowareLastSync')
espo_modified = espo_addr.get('modifiedAt')
# SPECIAL CASE: Kein lastSync → Initial Sync (EspoCRM bevorzugen)
if not last_sync:
logger.debug("Initial Sync (kein lastSync) → EspoCRM bevorzugt")
return 'espocrm_newer'
if espo_rowid and advo_rowid:
# Prüfe ob Advoware geändert wurde (rowId)
advo_changed = (espo_rowid != advo_rowid)
# Prüfe ob EspoCRM auch geändert wurde (seit letztem Sync)
espo_changed = False
if espo_modified and last_sync:
try:
# Parse Timestamps (ISO 8601 Format)
espo_ts = datetime.fromisoformat(espo_modified.replace('Z', '+00:00')) if isinstance(espo_modified, str) else espo_modified
sync_ts = datetime.fromisoformat(last_sync.replace('Z', '+00:00')) if isinstance(last_sync, str) else last_sync
espo_changed = (espo_ts > sync_ts)
except Exception as e:
logger.debug(f"Timestamp-Parse-Fehler: {e}")
# Konfliktlogik: Beide geändert seit letztem Sync?
if advo_changed and espo_changed:
logger.warning("🚨 KONFLIKT: Beide Seiten haben Adresse geändert seit letztem Sync")
return 'conflict'
elif advo_changed:
logger.info(f"Advoware rowId geändert: {espo_rowid[:20]}... → {advo_rowid[:20]}...")
return 'advoware_newer'
elif espo_changed:
logger.info("EspoCRM neuer (modifiedAt > lastSync)")
return 'espocrm_newer'
else:
# Keine Änderungen
logger.debug("Keine Änderungen (rowId identisch)")
return 'no_change'
# FALLBACK: Kein rowId vorhanden → konservativ EspoCRM bevorzugen
logger.debug("rowId nicht verfügbar → EspoCRM bevorzugt")
return 'espocrm_newer'
# ========================================================================
# CREATE: EspoCRM → Advoware
# ========================================================================
async def create_address(self, espo_addr: Dict[str, Any], betnr: int) -> Optional[Dict[str, Any]]:
"""
Erstelle neue Adresse in Advoware
Alle 11 Felder werden synchronisiert (inkl. READ-ONLY).
Args:
espo_addr: CAdressen Entity von EspoCRM
betnr: Advoware Beteiligte-Nummer
Returns:
Erstellte Adresse oder None bei Fehler
"""
try:
espo_id = espo_addr['id']
logger.info(f"Creating address in Advoware for EspoCRM ID {espo_id}, BetNr {betnr}")
# Map zu Advoware Format (alle Felder)
advo_data = self.mapper.map_cadressen_to_advoware_create(espo_addr)
# POST zu Advoware
result = await self.advo.api_call(
f'/api/v1/advonet/Beteiligte/{betnr}/Adressen',
method='POST',
json_data=advo_data
)
# POST gibt Array zurück, nimm erste Adresse
if isinstance(result, list) and result:
created_addr = result[0]
else:
created_addr = result
logger.info(
f"✓ Created address in Advoware: "
f"Index {created_addr.get('reihenfolgeIndex')}, "
f"EspoCRM ID {espo_id}"
)
# Update EspoCRM mit Sync-Info
await self._update_espo_sync_info(espo_id, created_addr, 'synced')
return created_addr
except Exception as e:
logger.error(f"Failed to create address: {e}", exc_info=True)
# Update syncStatus
await self._update_espo_sync_status(espo_addr['id'], 'error')
return None
# ========================================================================
# UPDATE: EspoCRM → Advoware (nur R/W Felder)
# ========================================================================
async def update_address(self, espo_addr: Dict[str, Any], betnr: int) -> Optional[Dict[str, Any]]:
"""
Update Adresse in Advoware (nur R/W Felder)
Nur strasse, plz, ort, anschrift werden geändert.
Alle anderen Änderungen → Notification.
Mit Konflikt-Erkennung: Wenn beide Seiten geändert → EspoCRM gewinnt!
Args:
espo_addr: CAdressen Entity von EspoCRM
betnr: Advoware Beteiligte-Nummer
Returns:
Aktualisierte Adresse oder None bei Fehler
"""
try:
espo_id = espo_addr['id']
logger.info(f"Updating address in Advoware for EspoCRM ID {espo_id}, BetNr {betnr}")
# 1. Finde Adresse in Advoware via bemerkung (EINZIGE stabile Methode)
target = await self._find_address_by_espo_id(betnr, espo_id)
if not target:
logger.warning(f"Address not found in Advoware: {espo_id} - creating new")
return await self.create_address(espo_addr, betnr)
# 2. KONFLIKT-CHECK: Vergleiche ob beide Seiten geändert wurden
comparison = self.compare_addresses(espo_addr, target)
if comparison == 'no_change':
logger.info(f"⏭ No changes detected, skipping update: {espo_id}")
return target
if comparison == 'advoware_newer':
logger.info(f"⬇ Advoware neuer, sync Advoware → EspoCRM statt Update")
# Advoware hat sich geändert, aber EspoCRM nicht
# → Sync in andere Richtung (aktualisiere EspoCRM)
await self._update_espo_address(
espo_id,
target,
espo_addr.get('beteiligteId'),
espo_addr
)
return target
# comparison ist 'espocrm_newer' oder 'conflict'
# In beiden Fällen: EspoCRM gewinnt!
if comparison == 'conflict':
logger.warning(
f"⚠️ KONFLIKT erkannt für Adresse {espo_id} - EspoCRM gewinnt! "
f"Überschreibe Advoware."
)
# 3. Map nur R/W Felder
rw_data = self.mapper.map_cadressen_to_advoware_update(espo_addr)
# 4. PUT mit aktuellem reihenfolgeIndex (dynamisch!)
current_index = target['reihenfolgeIndex']
result = await self.advo.api_call(
f'/api/v1/advonet/Beteiligte/{betnr}/Adressen/{current_index}',
method='PUT',
json_data=rw_data
)
# Extrahiere neue rowId aus Response
new_rowid = None
if isinstance(result, list) and result:
new_rowid = result[0].get('rowId')
elif isinstance(result, dict):
new_rowid = result.get('rowId')
logger.info(
f"✓ Updated address in Advoware (R/W fields): "
f"Index {current_index}, EspoCRM ID {espo_id}, neue rowId: {new_rowid[:20] if new_rowid else 'N/A'}..."
)
# 5. Bei Konflikt: Notification erstellen
if comparison == 'conflict':
await self._notify_conflict(
espo_addr,
betnr,
target,
f"Advoware rowId: {target.get('rowId', 'N/A')[:20]}..., "
f"EspoCRM modifiedAt: {espo_addr.get('modifiedAt', 'N/A')}"
)
# 6. Prüfe READ-ONLY Feld-Änderungen
readonly_changes = self.mapper.detect_readonly_changes(espo_addr, target)
if readonly_changes:
logger.warning(
f"⚠ READ-ONLY fields changed for {espo_id}: "
f"{len(readonly_changes)} fields"
)
await self._notify_readonly_changes(espo_addr, betnr, readonly_changes)
# 7. Update EspoCRM mit neuer Sync-Info (inkl. neuer rowId!)
result_with_rowid = result[0] if isinstance(result, list) and result else result
if new_rowid and isinstance(result_with_rowid, dict):
result_with_rowid['rowId'] = new_rowid
await self._update_espo_sync_info(espo_id, result_with_rowid, 'synced')
return result_with_rowid
except Exception as e:
logger.error(f"Failed to update address: {e}", exc_info=True)
# Update syncStatus
await self._update_espo_sync_status(espo_addr['id'], 'error')
return None
# ========================================================================
# DELETE: EspoCRM → Advoware (nur Notification)
# ========================================================================
async def handle_address_deletion(self, espo_addr: Dict[str, Any], betnr: int) -> bool:
"""
Handle Adress-Löschung (nur Notification)
Kein API-DELETE verfügbar → Manuelle Löschung erforderlich.
Args:
espo_addr: Gelöschte CAdressen Entity von EspoCRM
betnr: Advoware Beteiligte-Nummer
Returns:
True wenn Notification erfolgreich
"""
try:
espo_id = espo_addr['id']
logger.info(f"Handling address deletion for EspoCRM ID {espo_id}, BetNr {betnr}")
# 1. Finde Adresse in Advoware
target = await self._find_address_by_espo_id(betnr, espo_id)
if not target:
logger.info(f"Address already deleted or not found: {espo_id}")
return True
# 2. Erstelle Notification für manuelle Löschung
await self.notification_manager.notify_manual_action_required(
entity_type='CAdressen',
entity_id=espo_id,
action_type='address_delete_required',
details={
'message': 'Adresse in Advoware löschen',
'description': (
f'Adresse wurde in EspoCRM gelöscht:\n'
f'{target.get("strasse")}\n'
f'{target.get("plz")} {target.get("ort")}\n\n'
f'Bitte manuell in Advoware löschen:\n'
f'1. Öffne Beteiligten {betnr} in Advoware\n'
f'2. Gehe zu Adressen-Tab\n'
f'3. Lösche Adresse (Index {target.get("reihenfolgeIndex")})\n'
f'4. Speichern'
),
'advowareIndex': target.get('reihenfolgeIndex'),
'betnr': betnr,
'address': f"{target.get('strasse')}, {target.get('ort')}",
'priority': 'Medium'
}
)
logger.info(f"✓ Created delete notification for address {espo_id}")
return True
except Exception as e:
logger.error(f"Failed to handle address deletion: {e}", exc_info=True)
return False
# ========================================================================
# SYNC: Advoware → EspoCRM (vollständig)
# ========================================================================
async def sync_from_advoware(self, betnr: int, espo_beteiligte_id: str) -> Dict[str, int]:
"""
Synct alle Adressen von Advoware zu EspoCRM
Alle Felder werden übernommen (Advoware = Master).
Args:
betnr: Advoware Beteiligte-Nummer
espo_beteiligte_id: EspoCRM CBeteiligte ID
Returns:
Dict mit Statistiken: created, updated, unchanged
"""
stats = {'created': 0, 'updated': 0, 'unchanged': 0, 'errors': 0}
try:
logger.info(f"Syncing addresses from Advoware BetNr {betnr} → EspoCRM {espo_beteiligte_id}")
# 1. Hole alle Adressen von Advoware
advo_addresses = await self.advo.api_call(
f'/api/v1/advonet/Beteiligte/{betnr}/Adressen',
method='GET'
)
logger.info(f"Found {len(advo_addresses)} addresses in Advoware")
# 2. Hole existierende EspoCRM Adressen
import json
espo_addresses = await self.espo.list_entities(
'CAdressen',
where=json.dumps([{
'type': 'equals',
'attribute': 'beteiligteId',
'value': espo_beteiligte_id
}])
)
espo_addrs_by_id = {addr['id']: addr for addr in espo_addresses.get('list', [])}
# 3. Sync jede Adresse
for advo_addr in advo_addresses:
try:
# Match via bemerkung
bemerkung = advo_addr.get('bemerkung', '')
if 'EspoCRM-ID:' in bemerkung:
# Existierende Adresse
espo_id = bemerkung.split('EspoCRM-ID:')[1].strip().split()[0]
if espo_id in espo_addrs_by_id:
espo_addr = espo_addrs_by_id[espo_id]
# KONFLIKT-CHECK: Vergleiche ob beide Seiten geändert wurden
comparison = self.compare_addresses(espo_addr, advo_addr)
if comparison == 'no_change':
logger.debug(f"⏭ No changes detected, skipping: {espo_id}")
stats['unchanged'] += 1
elif comparison == 'espocrm_newer':
# EspoCRM ist neuer → Skip (wird von EspoCRM Webhook behandelt)
logger.info(f"⬆ EspoCRM neuer, skip sync: {espo_id}")
stats['unchanged'] += 1
elif comparison == 'conflict':
# Konflikt: EspoCRM gewinnt → Skip Update (EspoCRM bleibt Master)
logger.warning(
f"⚠️ KONFLIKT: EspoCRM ist Master, skip Advoware→EspoCRM update: {espo_id}"
)
stats['unchanged'] += 1
else:
# comparison == 'advoware_newer': Update EspoCRM
result = await self._update_espo_address(
espo_id,
advo_addr,
espo_beteiligte_id,
espo_addr
)
if result:
stats['updated'] += 1
else:
stats['errors'] += 1
else:
logger.warning(f"EspoCRM address not found: {espo_id}")
stats['errors'] += 1
else:
# Neue Adresse aus Advoware (kein EspoCRM-ID)
result = await self._create_espo_address(advo_addr, espo_beteiligte_id)
if result:
stats['created'] += 1
else:
stats['errors'] += 1
except Exception as e:
logger.error(f"Failed to sync address: {e}", exc_info=True)
stats['errors'] += 1
logger.info(
f"✓ Sync complete: "
f"created={stats['created']}, "
f"updated={stats['updated']}, "
f"errors={stats['errors']}"
)
return stats
except Exception as e:
logger.error(f"Failed to sync from Advoware: {e}", exc_info=True)
return stats
# ========================================================================
# HELPER METHODS
# ========================================================================
async def _find_address_by_espo_id(self, betnr: int, espo_id: str) -> Optional[Dict[str, Any]]:
"""
Finde Adresse in Advoware via bemerkung-Matching
Args:
betnr: Advoware Beteiligte-Nummer
espo_id: EspoCRM CAdressen ID
Returns:
Advoware Adresse oder None
"""
try:
all_addresses = await self.advo.api_call(
f'/api/v1/advonet/Beteiligte/{betnr}/Adressen',
method='GET'
)
bemerkung_match = f"EspoCRM-ID: {espo_id}"
target = next(
(a for a in all_addresses
if bemerkung_match in (a.get('bemerkung') or '')),
None
)
return target
except Exception as e:
logger.error(f"Failed to find address: {e}", exc_info=True)
return None
async def _update_espo_sync_info(self, espo_id: str, advo_addr: Dict[str, Any],
status: str = 'synced') -> bool:
"""
Update Sync-Info in EspoCRM CAdressen
Args:
espo_id: EspoCRM CAdressen ID
advo_addr: Advoware Adresse (für rowId)
status: syncStatus (nicht verwendet, da EspoCRM-Feld möglicherweise nicht existiert)
Returns:
True wenn erfolgreich
"""
try:
update_data = {
'advowareRowId': advo_addr.get('rowId'),
'advowareLastSync': datetime.now().isoformat()
# syncStatus removed - Feld existiert möglicherweise nicht
}
result = await self.espo.update_entity('CAdressen', espo_id, update_data)
return bool(result)
except Exception as e:
logger.error(f"Failed to update sync info: {e}", exc_info=True)
return False
async def _update_espo_sync_status(self, espo_id: str, status: str) -> bool:
"""
Update nur syncStatus in EspoCRM (optional - Feld möglicherweise nicht vorhanden)
Args:
espo_id: EspoCRM CAdressen ID
status: syncStatus ('error', 'pending', etc.)
Returns:
True wenn erfolgreich
"""
try:
# Feld möglicherweise nicht vorhanden - ignoriere Fehler
result = await self.espo.update_entity(
'CAdressen',
espo_id,
{'description': f'Sync-Status: {status}'} # Als Workaround in description
)
return bool(result)
except Exception as e:
logger.error(f"Failed to update sync status: {e}", exc_info=True)
return False
async def _notify_conflict(self, espo_addr: Dict[str, Any], betnr: int,
advo_addr: Dict[str, Any], conflict_details: str) -> bool:
"""
Erstelle Notification für Adress-Konflikt
Args:
espo_addr: EspoCRM CAdressen Entity
betnr: Advoware Beteiligte-Nummer
advo_addr: Advoware Adresse (für Details)
conflict_details: Details zum Konflikt
Returns:
True wenn Notification erfolgreich
"""
try:
await self.notification_manager.notify_manual_action_required(
entity_type='CAdressen',
entity_id=espo_addr['id'],
action_type='address_sync_conflict',
details={
'message': 'Sync-Konflikt bei Adresse (EspoCRM hat gewonnen)',
'description': (
f'Sowohl EspoCRM als auch Advoware haben diese Adresse seit '
f'dem letzten Sync geändert.\n\n'
f'EspoCRM hat Vorrang - Änderungen wurden nach Advoware übertragen.\n\n'
f'Details:\n{conflict_details}\n\n'
f'Bitte prüfen Sie die Daten in Advoware und stellen Sie sicher, '
f'dass keine wichtigen Änderungen verloren gegangen sind.'
),
'address': f"{espo_addr.get('adresseStreet')}, {espo_addr.get('adresseCity')}",
'betnr': betnr,
'priority': 'High'
}
)
return True
except Exception as e:
logger.error(f"Failed to create conflict notification: {e}", exc_info=True)
return False
async def _notify_readonly_changes(self, espo_addr: Dict[str, Any], betnr: int,
changes: List[Dict[str, Any]]) -> bool:
"""
Erstelle Notification für READ-ONLY Feld-Änderungen
Args:
espo_addr: EspoCRM CAdressen Entity
betnr: Advoware Beteiligte-Nummer
changes: Liste von Änderungen
Returns:
True wenn Notification erfolgreich
"""
try:
change_details = '\n'.join([
f"- {c['field']}: EspoCRM='{c['espoCRM_value']}'"
f"Advoware='{c['advoware_value']}'"
for c in changes
])
await self.notification_manager.notify_manual_action_required(
entity_type='CAdressen',
entity_id=espo_addr['id'],
action_type='readonly_field_conflict',
details={
'message': f'{len(changes)} READ-ONLY Feld(er) geändert',
'description': (
f'Folgende Felder wurden in EspoCRM geändert, sind aber '
f'READ-ONLY in Advoware und können nicht automatisch '
f'synchronisiert werden:\n\n{change_details}\n\n'
f'Bitte manuell in Advoware anpassen:\n'
f'1. Öffne Beteiligten {betnr} in Advoware\n'
f'2. Gehe zu Adressen-Tab\n'
f'3. Passe die Felder manuell an\n'
f'4. Speichern'
),
'changes': changes,
'address': f"{espo_addr.get('adresseStreet')}, "
f"{espo_addr.get('adresseCity')}",
'betnr': betnr,
'priority': 'High'
}
)
return True
except Exception as e:
logger.error(f"Failed to create notification: {e}", exc_info=True)
return False
async def _create_espo_address(self, advo_addr: Dict[str, Any],
beteiligte_id: str) -> Optional[str]:
"""
Erstelle neue Adresse in EspoCRM
Args:
advo_addr: Advoware Adresse
beteiligte_id: EspoCRM CBeteiligte ID
Returns:
EspoCRM ID oder None
"""
try:
espo_data = self.mapper.map_advoware_to_cadressen(advo_addr, beteiligte_id)
result = await self.espo.create_entity('CAdressen', espo_data)
if result and 'id' in result:
logger.info(f"✓ Created address in EspoCRM: {result['id']}")
return result['id']
return None
except Exception as e:
logger.error(f"Failed to create EspoCRM address: {e}", exc_info=True)
return None
async def _update_espo_address(self, espo_id: str, advo_addr: Dict[str, Any],
beteiligte_id: str,
existing: Dict[str, Any]) -> bool:
"""
Update existierende Adresse in EspoCRM
Args:
espo_id: EspoCRM CAdressen ID
advo_addr: Advoware Adresse
beteiligte_id: EspoCRM CBeteiligte ID
existing: Existierende EspoCRM Entity
Returns:
True wenn erfolgreich
"""
try:
espo_data = self.mapper.map_advoware_to_cadressen(
advo_addr,
beteiligte_id,
existing
)
result = await self.espo.update_entity('CAdressen', espo_id, espo_data)
if result:
logger.info(f"✓ Updated address in EspoCRM: {espo_id}")
return True
return False
except Exception as e:
logger.error(f"Failed to update EspoCRM address: {e}", exc_info=True)
return False

359
services/advoware.py Normal file
View File

@@ -0,0 +1,359 @@
"""Advoware API client for Motia III"""
import aiohttp
import asyncio
import time
import uuid
import hmac
import hashlib
import base64
import os
import datetime
from typing import Optional, Dict, Any
from services.exceptions import (
AdvowareAPIError,
AdvowareAuthError,
AdvowareTimeoutError,
RetryableError
)
from services.redis_client import get_redis_client
from services.config import ADVOWARE_CONFIG, API_CONFIG
from services.logging_utils import get_service_logger
class AdvowareAPI:
"""
Advoware API client with token caching via Redis.
Environment variables required:
- ADVOWARE_API_BASE_URL
- ADVOWARE_PRODUCT_ID
- ADVOWARE_APP_ID
- ADVOWARE_API_KEY (base64 encoded)
- ADVOWARE_KANZLEI
- ADVOWARE_DATABASE
- ADVOWARE_USER
- ADVOWARE_ROLE
- ADVOWARE_PASSWORD
"""
def __init__(self, context=None):
"""
Initialize Advoware API client.
Args:
context: Motia FlowContext for logging (optional)
"""
self.context = context
self.logger = get_service_logger('advoware', context)
self.logger.debug("AdvowareAPI initializing")
# Load configuration from environment
self.API_BASE_URL = os.getenv('ADVOWARE_API_BASE_URL', 'https://www2.advo-net.net:90/')
self.product_id = int(os.getenv('ADVOWARE_PRODUCT_ID', '64'))
self.app_id = os.getenv('ADVOWARE_APP_ID', '')
self.api_key = os.getenv('ADVOWARE_API_KEY', '')
self.kanzlei = os.getenv('ADVOWARE_KANZLEI', '')
self.database = os.getenv('ADVOWARE_DATABASE', '')
self.user = os.getenv('ADVOWARE_USER', '')
self.role = int(os.getenv('ADVOWARE_ROLE', '2'))
self.password = os.getenv('ADVOWARE_PASSWORD', '')
self.token_lifetime_minutes = ADVOWARE_CONFIG.token_lifetime_minutes
self.api_timeout_seconds = API_CONFIG.default_timeout_seconds
# Initialize Redis for token caching (centralized)
self.redis_client = get_redis_client(strict=False)
if self.redis_client:
self.logger.info("Connected to Redis for token caching")
else:
self.logger.warning("⚠️ Redis unavailable - token caching disabled!")
self.logger.info("AdvowareAPI initialized")
self._session: Optional[aiohttp.ClientSession] = None
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 _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
def _generate_hmac(self, request_time_stamp: str, nonce: Optional[str] = None) -> str:
"""Generate HMAC-SHA512 signature for authentication"""
if not nonce:
nonce = str(uuid.uuid4())
message = f"{self.product_id}:{self.app_id}:{nonce}:{request_time_stamp}".encode('utf-8')
try:
api_key_bytes = base64.b64decode(self.api_key)
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
signature = hmac.new(api_key_bytes, message, hashlib.sha512)
return base64.b64encode(signature.digest()).decode('utf-8')
async def _fetch_new_access_token(self) -> str:
"""Fetch new access token from Advoware Auth API (async)"""
self.logger.info("Fetching new access token from Advoware")
nonce = str(uuid.uuid4())
request_time_stamp = datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
hmac_signature = self._generate_hmac(request_time_stamp, nonce)
headers = {'Content-Type': 'application/json'}
data = {
"AppID": self.app_id,
"Kanzlei": self.kanzlei,
"Database": self.database,
"User": self.user,
"Role": self.role,
"Product": self.product_id,
"Password": self.password,
"Nonce": nonce,
"HMAC512Signature": hmac_signature,
"RequestTimeStamp": request_time_stamp
}
self.logger.debug(f"Token request: AppID={self.app_id}, User={self.user}")
# Async token fetch using aiohttp
session = await self._get_session()
try:
async with session.post(
ADVOWARE_CONFIG.auth_url,
json=data,
headers=headers,
timeout=aiohttp.ClientTimeout(total=self.api_timeout_seconds)
) as response:
self.logger.debug(f"Token response status: {response.status}")
if response.status == 401:
raise AdvowareAuthError(
"Authentication failed - check credentials",
status_code=401
)
if response.status >= 400:
error_text = await response.text()
raise AdvowareAPIError(
f"Token request failed ({response.status}): {error_text}",
status_code=response.status
)
result = await response.json()
except asyncio.TimeoutError:
raise AdvowareTimeoutError(
"Token request timed out",
status_code=408
)
except aiohttp.ClientError as e:
raise AdvowareAPIError(f"Token request failed: {str(e)}")
access_token = result.get("access_token")
if not access_token:
self.logger.error("No access_token in response")
raise AdvowareAuthError("No access_token received from Advoware")
self.logger.info("Access token fetched successfully")
# Cache token in Redis
if self.redis_client:
effective_ttl = max(1, (self.token_lifetime_minutes - 2) * 60)
self.redis_client.set(ADVOWARE_CONFIG.token_cache_key, access_token, ex=effective_ttl)
self.redis_client.set(ADVOWARE_CONFIG.token_timestamp_key, str(time.time()), ex=effective_ttl)
self.logger.debug(f"Token cached in Redis with TTL {effective_ttl}s")
return access_token
async def get_access_token(self, force_refresh: bool = False) -> str:
"""
Get valid access token (from cache or fetch new).
Args:
force_refresh: Force token refresh even if cached
Returns:
Valid access token
"""
self.logger.debug("Getting access token")
if not self.redis_client:
self.logger.info("No Redis available, fetching new token")
return await self._fetch_new_access_token()
if force_refresh:
self.logger.info("Force refresh requested, fetching new token")
return await self._fetch_new_access_token()
# Check cache
cached_token = self.redis_client.get(ADVOWARE_CONFIG.token_cache_key)
token_timestamp = self.redis_client.get(ADVOWARE_CONFIG.token_timestamp_key)
if cached_token and token_timestamp:
try:
# Redis decode_responses=True returns strings
timestamp = float(token_timestamp)
age_seconds = time.time() - timestamp
if age_seconds < (self.token_lifetime_minutes - 1) * 60:
self.logger.debug(f"Using cached token (age: {age_seconds:.0f}s)")
return cached_token
except (ValueError, AttributeError, TypeError) as e:
self.logger.debug(f"Error reading cached token: {e}")
self.logger.info("Cached token expired or invalid, fetching new")
return await self._fetch_new_access_token()
async def api_call(
self,
endpoint: str,
method: str = 'GET',
headers: Optional[Dict] = None,
params: Optional[Dict] = None,
json_data: Optional[Dict] = None,
files: Optional[Any] = None,
data: Optional[Any] = None,
timeout_seconds: Optional[int] = None
) -> Any:
"""
Make async API call to Advoware.
Args:
endpoint: API endpoint (without base URL)
method: HTTP method
headers: Optional headers
params: Optional query parameters
json_data: Optional JSON body
files: Optional files (not implemented)
data: Optional raw data (overrides json_data)
timeout_seconds: Optional timeout override
Returns:
JSON response or None
Raises:
AdvowareAuthError: Authentication failed
AdvowareTimeoutError: Request timed out
AdvowareAPIError: Other API errors
"""
# Clean endpoint
endpoint = endpoint.lstrip('/')
url = self.API_BASE_URL.rstrip('/') + '/' + endpoint
effective_timeout = aiohttp.ClientTimeout(
total=timeout_seconds or self.api_timeout_seconds
)
# Get auth token
try:
token = await self.get_access_token()
except AdvowareAuthError:
raise
except Exception as e:
raise AdvowareAPIError(f"Failed to get access token: {str(e)}")
# Prepare headers
effective_headers = headers.copy() if headers else {}
effective_headers['Authorization'] = f'Bearer {token}'
effective_headers.setdefault('Content-Type', 'application/json')
# Use 'data' parameter if provided, otherwise 'json_data'
json_payload = data if data is not None else json_data
session = await self._get_session()
try:
with self.logger.api_call(endpoint, method):
async with session.request(
method,
url,
headers=effective_headers,
params=params,
json=json_payload,
timeout=effective_timeout
) as response:
# Handle 401 - retry with fresh token
if response.status == 401:
self.logger.warning("401 Unauthorized, refreshing token")
token = await self.get_access_token(force_refresh=True)
effective_headers['Authorization'] = f'Bearer {token}'
async with session.request(
method,
url,
headers=effective_headers,
params=params,
json=json_payload,
timeout=effective_timeout
) as retry_response:
if retry_response.status == 401:
raise AdvowareAuthError(
"Authentication failed even after token refresh",
status_code=401
)
if retry_response.status >= 500:
error_text = await retry_response.text()
raise RetryableError(
f"Server error {retry_response.status}: {error_text}"
)
retry_response.raise_for_status()
return await self._parse_response(retry_response)
# Handle other error codes
if response.status == 404:
error_text = await response.text()
raise AdvowareAPIError(
f"Resource not found: {endpoint}",
status_code=404,
response_body=error_text
)
if response.status >= 500:
error_text = await response.text()
raise RetryableError(
f"Server error {response.status}: {error_text}"
)
if response.status >= 400:
error_text = await response.text()
raise AdvowareAPIError(
f"API error {response.status}: {error_text}",
status_code=response.status,
response_body=error_text
)
return await self._parse_response(response)
except asyncio.TimeoutError:
raise AdvowareTimeoutError(
f"Request timed out after {effective_timeout.total}s",
status_code=408
)
except aiohttp.ClientError as e:
self.logger.error(f"API call failed: {e}")
raise AdvowareAPIError(f"Request failed: {str(e)}")
async def _parse_response(self, response: aiohttp.ClientResponse) -> Any:
"""Parse API response"""
if response.content_type == 'application/json':
try:
return await response.json()
except Exception as e:
self.logger.debug(f"JSON parse error: {e}")
return None
return None

View File

@@ -0,0 +1,129 @@
"""
Advoware Service Wrapper
Extends AdvowareAPI with higher-level operations for business logic.
"""
from typing import Dict, Any, Optional
from services.advoware import AdvowareAPI
from services.logging_utils import get_service_logger
class AdvowareService:
"""
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"""
return await self.api.api_call(*args, **kwargs)
# ========== BETEILIGTE ==========
async def get_beteiligter(self, betnr: int) -> Optional[Dict[str, Any]]:
"""
Load Beteiligte with all data.
Returns:
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:
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[str, Any]]:
"""
Create new Kommunikation.
Args:
betnr: Beteiligte number
data: {
'tlf': str, # Required
'bemerkung': str, # Optional
'kommKz': int, # Required (1-12)
'online': bool # Optional
}
Returns:
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:
self._log(f"[ADVO] ✅ Created Kommunikation: betnr={betnr}, kommKz={data.get('kommKz')}")
return result
except Exception as e:
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:
"""
Update existing Kommunikation.
Args:
betnr: Beteiligte number
komm_id: Kommunikation ID
data: {
'tlf': str, # Optional
'bemerkung': str, # Optional
'online': bool # Optional
}
NOTE: kommKz is READ-ONLY and cannot be changed
Returns:
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)
self._log(f"[ADVO] ✅ Updated Kommunikation: betnr={betnr}, komm_id={komm_id}")
return True
except Exception as e:
self._log(f"[ADVO] Error updating Kommunikation: {e}", level='error')
return False
async def delete_kommunikation(self, betnr: int, komm_id: int) -> bool:
"""
Delete Kommunikation (currently returns 403 Forbidden).
NOTE: DELETE is disabled in Advoware API.
Use empty slots with empty_slot_marker instead.
Returns:
True if successful
"""
try:
endpoint = f"api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{komm_id}"
await self.api.api_call(endpoint, method='DELETE')
self._log(f"[ADVO] ✅ Deleted Kommunikation: betnr={betnr}, komm_id={komm_id}")
return True
except Exception as e:
# Expected: 403 Forbidden
self._log(f"[ADVO] DELETE not allowed (expected): {e}", level='warning')
return False

View File

@@ -0,0 +1,545 @@
"""
AI Knowledge Sync Utilities
Utility functions for synchronizing CAIKnowledge entities with XAI Collections:
- Collection lifecycle management (create, delete)
- Document synchronization with BLAKE3 hash verification
- Metadata-only updates via PATCH
- Orphan detection and cleanup
"""
import hashlib
import json
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime
from urllib.parse import unquote
from services.sync_utils_base import BaseSyncUtils
from services.models import (
AIKnowledgeActivationStatus,
AIKnowledgeSyncStatus,
JunctionSyncStatus
)
class AIKnowledgeSync(BaseSyncUtils):
"""Utility class for AI Knowledge ↔ XAI Collections synchronization"""
def _get_lock_key(self, entity_id: str) -> str:
"""Redis lock key for AI Knowledge entities"""
return f"sync_lock:aiknowledge:{entity_id}"
async def acquire_sync_lock(self, knowledge_id: str) -> bool:
"""
Acquire distributed lock via Redis + update EspoCRM syncStatus.
Args:
knowledge_id: CAIKnowledge entity ID
Returns:
True if lock acquired, False if already locked
"""
try:
# STEP 1: Atomic Redis lock
lock_key = self._get_lock_key(knowledge_id)
if not self._acquire_redis_lock(lock_key):
self._log(f"Redis lock already active for {knowledge_id}", level='warn')
return False
# STEP 2: Update syncStatus to pending_sync
try:
await self.espocrm.update_entity('CAIKnowledge', knowledge_id, {
'syncStatus': AIKnowledgeSyncStatus.PENDING_SYNC.value
})
except Exception as e:
self._log(f"Could not set syncStatus: {e}", level='debug')
self._log(f"Sync lock acquired for {knowledge_id}")
return True
except Exception as e:
self._log(f"Error acquiring lock: {e}", level='error')
# Clean up Redis lock on error
lock_key = self._get_lock_key(knowledge_id)
self._release_redis_lock(lock_key)
return False
async def release_sync_lock(
self,
knowledge_id: str,
success: bool = True,
error_message: Optional[str] = None
) -> None:
"""
Release sync lock and set final status.
Args:
knowledge_id: CAIKnowledge entity ID
success: Whether sync succeeded
error_message: Optional error message
"""
try:
update_data = {
'syncStatus': AIKnowledgeSyncStatus.SYNCED.value if success else AIKnowledgeSyncStatus.FAILED.value
}
if success:
update_data['lastSync'] = datetime.now().isoformat()
update_data['syncError'] = None
elif error_message:
update_data['syncError'] = error_message[:2000]
await self.espocrm.update_entity('CAIKnowledge', knowledge_id, update_data)
self._log(f"Sync lock released: {knowledge_id}{'success' if success else 'failed'}")
# Release Redis lock
lock_key = self._get_lock_key(knowledge_id)
self._release_redis_lock(lock_key)
except Exception as e:
self._log(f"Error releasing lock: {e}", level='error')
# Ensure Redis lock is released
lock_key = self._get_lock_key(knowledge_id)
self._release_redis_lock(lock_key)
async def sync_knowledge_to_xai(self, knowledge_id: str, ctx) -> None:
"""
Main sync orchestrator with activation status handling.
Args:
knowledge_id: CAIKnowledge entity ID
ctx: Motia context for logging
"""
from services.espocrm import EspoCRMAPI
from services.xai_service import XAIService
espocrm = EspoCRMAPI(ctx)
xai = XAIService(ctx)
try:
# 1. Load knowledge entity
knowledge = await espocrm.get_entity('CAIKnowledge', knowledge_id)
activation_status = knowledge.get('aktivierungsstatus')
collection_id = knowledge.get('datenbankId')
ctx.logger.info("=" * 80)
ctx.logger.info(f"📋 Processing: {knowledge['name']}")
ctx.logger.info(f" aktivierungsstatus: {activation_status}")
ctx.logger.info(f" datenbankId: {collection_id or 'NONE'}")
ctx.logger.info("=" * 80)
# ═══════════════════════════════════════════════════════════
# CASE 1: NEW → Create Collection
# ═══════════════════════════════════════════════════════════
if activation_status == AIKnowledgeActivationStatus.NEW.value:
ctx.logger.info("🆕 Status 'new' → Creating XAI Collection")
collection = await xai.create_collection(
name=knowledge['name'],
metadata={
'espocrm_entity_type': 'CAIKnowledge',
'espocrm_entity_id': knowledge_id,
'created_at': datetime.now().isoformat()
}
)
# XAI API returns 'collection_id' not 'id'
collection_id = collection.get('collection_id') or collection.get('id')
# Update EspoCRM: Set datenbankId + change status to 'active'
await espocrm.update_entity('CAIKnowledge', knowledge_id, {
'datenbankId': collection_id,
'aktivierungsstatus': AIKnowledgeActivationStatus.ACTIVE.value,
'syncStatus': AIKnowledgeSyncStatus.UNCLEAN.value
})
ctx.logger.info(f"✅ Collection created: {collection_id}")
ctx.logger.info(" Status changed to 'active', now syncing documents...")
# Continue to document sync immediately (don't return)
# Fall through to sync logic below
# ═══════════════════════════════════════════════════════════
# CASE 2: DEACTIVATED → Delete Collection from XAI
# ═══════════════════════════════════════════════════════════
elif activation_status == AIKnowledgeActivationStatus.DEACTIVATED.value:
ctx.logger.info("🗑️ Status 'deactivated' → Deleting XAI Collection")
if collection_id:
try:
await xai.delete_collection(collection_id)
ctx.logger.info(f"✅ Collection deleted from XAI: {collection_id}")
except Exception as e:
ctx.logger.error(f"❌ Failed to delete collection: {e}")
else:
ctx.logger.info("⏭️ No collection ID, nothing to delete")
# Reset junction entries
documents = await espocrm.get_knowledge_documents_with_junction(knowledge_id)
for doc in documents:
doc_id = doc['documentId']
try:
await espocrm.update_knowledge_document_junction(
knowledge_id,
doc_id,
{
'syncstatus': 'new',
'aiDocumentId': None
},
update_last_sync=False
)
except Exception as e:
ctx.logger.warn(f"⚠️ Failed to reset junction for {doc_id}: {e}")
ctx.logger.info(f"✅ Deactivation complete, {len(documents)} junction entries reset")
return
# ═══════════════════════════════════════════════════════════
# CASE 3: PAUSED → Skip Sync
# ═══════════════════════════════════════════════════════════
elif activation_status == AIKnowledgeActivationStatus.PAUSED.value:
ctx.logger.info("⏸️ Status 'paused' → No sync performed")
return
# ═══════════════════════════════════════════════════════════
# CASE 4: ACTIVE → Normal Sync (or just created from NEW)
# ═══════════════════════════════════════════════════════════
if activation_status in (AIKnowledgeActivationStatus.ACTIVE.value, AIKnowledgeActivationStatus.NEW.value):
if not collection_id:
ctx.logger.error("❌ Status 'active' but no datenbankId!")
raise RuntimeError("Active knowledge without collection ID")
if activation_status == AIKnowledgeActivationStatus.ACTIVE.value:
ctx.logger.info(f"🔄 Status 'active' → Syncing documents to {collection_id}")
# Verify collection exists
collection = await xai.get_collection(collection_id)
if not collection:
ctx.logger.warn(f"⚠️ Collection {collection_id} not found, recreating")
collection = await xai.create_collection(
name=knowledge['name'],
metadata={
'espocrm_entity_type': 'CAIKnowledge',
'espocrm_entity_id': knowledge_id
}
)
collection_id = collection['id']
await espocrm.update_entity('CAIKnowledge', knowledge_id, {
'datenbankId': collection_id
})
# Sync documents (both for ACTIVE status and after NEW → ACTIVE transition)
await self._sync_knowledge_documents(knowledge_id, collection_id, ctx)
elif activation_status not in (AIKnowledgeActivationStatus.DEACTIVATED.value, AIKnowledgeActivationStatus.PAUSED.value):
ctx.logger.error(f"❌ Unknown aktivierungsstatus: {activation_status}")
raise ValueError(f"Invalid aktivierungsstatus: {activation_status}")
finally:
await xai.close()
async def _sync_knowledge_documents(
self,
knowledge_id: str,
collection_id: str,
ctx
) -> None:
"""
Sync all documents of a knowledge base to XAI collection.
Uses efficient JunctionData endpoint to get all documents with junction data
and blake3 hashes in a single API call. Hash comparison is always performed.
Args:
knowledge_id: CAIKnowledge entity ID
collection_id: XAI Collection ID
ctx: Motia context
"""
from services.espocrm import EspoCRMAPI
from services.xai_service import XAIService
espocrm = EspoCRMAPI(ctx)
xai = XAIService(ctx)
# ═══════════════════════════════════════════════════════════════
# STEP 1: Load all documents with junction data (single API call)
# ═══════════════════════════════════════════════════════════════
ctx.logger.info(f"📥 Loading documents with junction data for knowledge {knowledge_id}")
documents = await espocrm.get_knowledge_documents_with_junction(knowledge_id)
ctx.logger.info(f"📊 Found {len(documents)} document(s)")
if not documents:
ctx.logger.info("✅ No documents to sync")
return
# ═══════════════════════════════════════════════════════════════
# STEP 2: Sync each document based on status/hash
# ═══════════════════════════════════════════════════════════════
successful = 0
failed = 0
skipped = 0
# Track aiDocumentIds for orphan detection (collected during sync)
synced_file_ids: set = set()
for doc in documents:
doc_id = doc['documentId']
doc_name = doc.get('documentName', 'Unknown')
junction_status = doc.get('syncstatus', 'new')
ai_document_id = doc.get('aiDocumentId')
blake3_hash = doc.get('blake3hash')
ctx.logger.info(f"\n📄 {doc_name} (ID: {doc_id})")
ctx.logger.info(f" Status: {junction_status}")
ctx.logger.info(f" aiDocumentId: {ai_document_id or 'N/A'}")
ctx.logger.info(f" blake3hash: {blake3_hash[:16] if blake3_hash else 'N/A'}...")
try:
# Decide if sync needed
needs_sync = False
reason = ""
if junction_status in ['new', 'unclean', 'failed']:
needs_sync = True
reason = f"status={junction_status}"
elif junction_status == 'synced':
# Synced status should have both blake3_hash and ai_document_id
if not blake3_hash:
needs_sync = True
reason = "inconsistency: synced but no blake3 hash"
ctx.logger.warn(f" ⚠️ Synced document missing blake3 hash!")
elif not ai_document_id:
needs_sync = True
reason = "inconsistency: synced but no aiDocumentId"
ctx.logger.warn(f" ⚠️ Synced document missing aiDocumentId!")
else:
# Verify Blake3 hash with XAI (always, since hash from JunctionData API is free)
try:
xai_doc_info = await xai.get_collection_document(collection_id, ai_document_id)
if xai_doc_info:
xai_blake3 = xai_doc_info.get('blake3_hash')
if xai_blake3 != blake3_hash:
needs_sync = True
reason = f"blake3 mismatch (XAI: {xai_blake3[:16] if xai_blake3 else 'N/A'}... vs EspoCRM: {blake3_hash[:16]}...)"
ctx.logger.info(f" 🔄 Blake3 mismatch detected!")
else:
ctx.logger.info(f" ✅ Blake3 hash matches")
else:
needs_sync = True
reason = "file not found in XAI collection"
ctx.logger.warn(f" ⚠️ Document marked synced but not in XAI!")
except Exception as e:
needs_sync = True
reason = f"verification failed: {e}"
ctx.logger.warn(f" ⚠️ Failed to verify Blake3, will re-sync: {e}")
if not needs_sync:
ctx.logger.info(f" ⏭️ Skipped (no sync needed)")
# Document is already synced, track its aiDocumentId
if ai_document_id:
synced_file_ids.add(ai_document_id)
skipped += 1
continue
ctx.logger.info(f" 🔄 Syncing: {reason}")
# Get complete document entity with attachment info
doc_entity = await espocrm.get_entity('CDokumente', doc_id)
attachment_id = doc_entity.get('dokumentId')
if not attachment_id:
ctx.logger.error(f" ❌ No attachment ID found for document {doc_id}")
failed += 1
continue
# Get attachment details for MIME type and original filename
try:
attachment = await espocrm.get_entity('Attachment', attachment_id)
mime_type = attachment.get('type', 'application/octet-stream')
file_size = attachment.get('size', 0)
original_filename = attachment.get('name', doc_name) # Original filename with extension
# URL-decode filename (fixes special chars like §, ä, ö, ü, etc.)
original_filename = unquote(original_filename)
except Exception as e:
ctx.logger.warn(f" ⚠️ Failed to get attachment details: {e}, using defaults")
mime_type = 'application/octet-stream'
file_size = 0
original_filename = unquote(doc_name) # Also decode fallback name
ctx.logger.info(f" 📎 Attachment: {attachment_id} ({mime_type}, {file_size} bytes)")
ctx.logger.info(f" 📄 Original filename: {original_filename}")
# Download document
file_content = await espocrm.download_attachment(attachment_id)
ctx.logger.info(f" 📥 Downloaded {len(file_content)} bytes")
# Upload to XAI with original filename (includes extension)
filename = original_filename
xai_file_id = await xai.upload_file(file_content, filename, mime_type)
ctx.logger.info(f" 📤 Uploaded to XAI: {xai_file_id}")
# Add to collection
await xai.add_to_collection(collection_id, xai_file_id)
ctx.logger.info(f" ✅ Added to collection {collection_id}")
# Update junction
await espocrm.update_knowledge_document_junction(
knowledge_id,
doc_id,
{
'aiDocumentId': xai_file_id,
'syncstatus': 'synced'
},
update_last_sync=True
)
ctx.logger.info(f" ✅ Junction updated")
# Track the new aiDocumentId for orphan detection
synced_file_ids.add(xai_file_id)
successful += 1
except Exception as e:
failed += 1
ctx.logger.error(f" ❌ Sync failed: {e}")
# Mark as failed in junction
try:
await espocrm.update_knowledge_document_junction(
knowledge_id,
doc_id,
{'syncstatus': 'failed'},
update_last_sync=False
)
except Exception as update_err:
ctx.logger.error(f" ❌ Failed to update junction status: {update_err}")
# ═══════════════════════════════════════════════════════════════
# STEP 3: Remove orphaned documents from XAI collection
# ═══════════════════════════════════════════════════════════════
try:
ctx.logger.info(f"\n🧹 Checking for orphaned documents in XAI collection...")
# Get all files in XAI collection (normalized structure)
xai_documents = await xai.list_collection_documents(collection_id)
xai_file_ids = {doc.get('file_id') for doc in xai_documents if doc.get('file_id')}
# Use synced_file_ids (collected during this sync) for orphan detection
# This includes both pre-existing synced docs and newly uploaded ones
ctx.logger.info(f" XAI has {len(xai_file_ids)} files, we have {len(synced_file_ids)} synced")
# Find orphans (in XAI but not in our current sync)
orphans = xai_file_ids - synced_file_ids
if orphans:
ctx.logger.info(f" Found {len(orphans)} orphaned file(s)")
for orphan_id in orphans:
try:
await xai.remove_from_collection(collection_id, orphan_id)
ctx.logger.info(f" 🗑️ Removed {orphan_id}")
except Exception as e:
ctx.logger.warn(f" ⚠️ Failed to remove {orphan_id}: {e}")
else:
ctx.logger.info(f" ✅ No orphans found")
except Exception as e:
ctx.logger.warn(f"⚠️ Failed to clean up orphans: {e}")
# ═══════════════════════════════════════════════════════════════
# STEP 4: Summary
# ═══════════════════════════════════════════════════════════════
ctx.logger.info("")
ctx.logger.info("=" * 80)
ctx.logger.info(f"📊 Sync Statistics:")
ctx.logger.info(f" ✅ Synced: {successful}")
ctx.logger.info(f" ⏭️ Skipped: {skipped}")
ctx.logger.info(f" ❌ Failed: {failed}")
ctx.logger.info(f" Mode: Blake3 hash verification enabled")
ctx.logger.info("=" * 80)
def _calculate_metadata_hash(self, document: Dict) -> str:
"""
Calculate hash of sync-relevant metadata.
Args:
document: CDokumente entity
Returns:
MD5 hash (32 chars)
"""
metadata = {
'name': document.get('name', ''),
'description': document.get('description', ''),
}
metadata_str = json.dumps(metadata, sort_keys=True)
return hashlib.md5(metadata_str.encode()).hexdigest()
def _build_xai_metadata(self, document: Dict) -> Dict[str, str]:
"""
Build XAI metadata from CDokumente entity.
Args:
document: CDokumente entity
Returns:
Metadata dict for XAI
"""
return {
'document_name': document.get('name', ''),
'description': document.get('description', ''),
'created_at': document.get('createdAt', ''),
'modified_at': document.get('modifiedAt', ''),
'espocrm_id': document.get('id', '')
}
async def _get_document_download_info(
self,
document: Dict,
ctx
) -> Optional[Dict[str, Any]]:
"""
Get download info for CDokumente entity.
Args:
document: CDokumente entity
ctx: Motia context
Returns:
Dict with attachment_id, filename, mime_type
"""
from services.espocrm import EspoCRMAPI
espocrm = EspoCRMAPI(ctx)
# Check for dokumentId (CDokumente custom field)
attachment_id = None
filename = None
if document.get('dokumentId'):
attachment_id = document.get('dokumentId')
filename = document.get('dokumentName')
elif document.get('fileId'):
attachment_id = document.get('fileId')
filename = document.get('fileName')
if not attachment_id:
ctx.logger.error(f"❌ No attachment ID for document {document['id']}")
return None
# Get attachment details
try:
attachment = await espocrm.get_entity('Attachment', attachment_id)
return {
'attachment_id': attachment_id,
'filename': filename or attachment.get('name', 'unknown'),
'mime_type': attachment.get('type', 'application/octet-stream')
}
except Exception as e:
ctx.logger.error(f"❌ Failed to get attachment {attachment_id}: {e}")
return None

View File

@@ -0,0 +1,110 @@
"""Aktenzeichen-Erkennung und Validation
Utility functions für das Erkennen, Validieren und Normalisieren von
Aktenzeichen im Format '1234/56' oder 'ABC/23'.
"""
import re
from typing import Optional
# Regex für Aktenzeichen: 1-4 Zeichen (alphanumerisch) + "/" + 2 Ziffern
AKTENZEICHEN_REGEX = re.compile(r'^([A-Za-z0-9]{1,4}/\d{2})\s*', re.IGNORECASE)
def extract_aktenzeichen(text: str) -> Optional[str]:
"""
Extrahiert Aktenzeichen vom Anfang des Textes.
Pattern: ^[A-Za-z0-9]{1,4}/\d{2}
Examples:
>>> extract_aktenzeichen("1234/56 Was ist der Stand?")
"1234/56"
>>> extract_aktenzeichen("ABC/23 Frage zum Vertrag")
"ABC/23"
>>> extract_aktenzeichen("Kein Aktenzeichen hier")
None
Args:
text: Eingabetext (z.B. erste Message)
Returns:
Aktenzeichen als String, oder None wenn nicht gefunden
"""
if not text or not isinstance(text, str):
return None
match = AKTENZEICHEN_REGEX.match(text.strip())
return match.group(1) if match else None
def remove_aktenzeichen(text: str) -> str:
"""
Entfernt Aktenzeichen vom Anfang des Textes.
Examples:
>>> remove_aktenzeichen("1234/56 Was ist der Stand?")
"Was ist der Stand?"
>>> remove_aktenzeichen("Kein Aktenzeichen")
"Kein Aktenzeichen"
Args:
text: Eingabetext mit Aktenzeichen
Returns:
Text ohne Aktenzeichen (whitespace getrimmt)
"""
if not text or not isinstance(text, str):
return text
return AKTENZEICHEN_REGEX.sub('', text, count=1).strip()
def validate_aktenzeichen(az: str) -> bool:
"""
Validiert Aktenzeichen-Format.
Pattern: ^[A-Za-z0-9]{1,4}/\d{2}$
Examples:
>>> validate_aktenzeichen("1234/56")
True
>>> validate_aktenzeichen("ABC/23")
True
>>> validate_aktenzeichen("12345/567") # Zu lang
False
>>> validate_aktenzeichen("1234-56") # Falsches Trennzeichen
False
Args:
az: Aktenzeichen zum Validieren
Returns:
True wenn valide, False sonst
"""
if not az or not isinstance(az, str):
return False
return bool(re.match(r'^[A-Za-z0-9]{1,4}/\d{2}$', az, re.IGNORECASE))
def normalize_aktenzeichen(az: str) -> str:
"""
Normalisiert Aktenzeichen (uppercase, trim whitespace).
Examples:
>>> normalize_aktenzeichen("abc/23")
"ABC/23"
>>> normalize_aktenzeichen(" 1234/56 ")
"1234/56"
Args:
az: Aktenzeichen zum Normalisieren
Returns:
Normalisiertes Aktenzeichen (uppercase, getrimmt)
"""
if not az or not isinstance(az, str):
return az
return az.strip().upper()

View File

@@ -0,0 +1,171 @@
"""
EspoCRM ↔ Advoware Bankverbindungen Mapper
Transformiert Bankverbindungen zwischen den beiden Systemen
"""
from typing import Dict, Any, Optional, List
from datetime import datetime
class BankverbindungenMapper:
"""Mapper für CBankverbindungen (EspoCRM) ↔ Bankverbindung (Advoware)"""
@staticmethod
def map_cbankverbindungen_to_advoware(espo_entity: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert EspoCRM CBankverbindungen → Advoware Bankverbindung Format
Args:
espo_entity: CBankverbindungen Entity von EspoCRM
Returns:
Dict für Advoware API (POST/PUT /api/v1/advonet/Beteiligte/{id}/Bankverbindungen)
"""
logger.debug(f"Mapping EspoCRM → Advoware Bankverbindung: {espo_entity.get('id')}")
advo_data = {}
# Bankname
bank = espo_entity.get('bank')
if bank:
advo_data['bank'] = bank
# Kontonummer (deprecated, aber noch supported)
kto_nr = espo_entity.get('kontoNummer')
if kto_nr:
advo_data['ktoNr'] = kto_nr
# BLZ (deprecated, aber noch supported)
blz = espo_entity.get('blz')
if blz:
advo_data['blz'] = blz
# IBAN
iban = espo_entity.get('iban')
if iban:
advo_data['iban'] = iban
# BIC
bic = espo_entity.get('bic')
if bic:
advo_data['bic'] = bic
# Kontoinhaber
kontoinhaber = espo_entity.get('kontoinhaber')
if kontoinhaber:
advo_data['kontoinhaber'] = kontoinhaber
# SEPA Mandat
mandatsreferenz = espo_entity.get('mandatsreferenz')
if mandatsreferenz:
advo_data['mandatsreferenz'] = mandatsreferenz
mandat_vom = espo_entity.get('mandatVom')
if mandat_vom:
advo_data['mandatVom'] = mandat_vom
logger.debug(f"Mapped to Advoware: IBAN={advo_data.get('iban')}, Bank={advo_data.get('bank')}")
return advo_data
@staticmethod
def map_advoware_to_cbankverbindungen(advo_entity: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert Advoware Bankverbindung → EspoCRM CBankverbindungen Format
Args:
advo_entity: Bankverbindung von Advoware API
Returns:
Dict für EspoCRM API (POST/PUT /api/v1/CBankverbindungen)
"""
logger.debug(f"Mapping Advoware → EspoCRM: id={advo_entity.get('id')}")
espo_data = {
'advowareId': advo_entity.get('id'), # Link zu Advoware
'advowareRowId': advo_entity.get('rowId'), # Änderungserkennung
}
# Bankname
bank = advo_entity.get('bank')
if bank:
espo_data['bank'] = bank
# Kontonummer
kto_nr = advo_entity.get('ktoNr')
if kto_nr:
espo_data['kontoNummer'] = kto_nr
# BLZ
blz = advo_entity.get('blz')
if blz:
espo_data['blz'] = blz
# IBAN
iban = advo_entity.get('iban')
if iban:
espo_data['iban'] = iban
# BIC
bic = advo_entity.get('bic')
if bic:
espo_data['bic'] = bic
# Kontoinhaber
kontoinhaber = advo_entity.get('kontoinhaber')
if kontoinhaber:
espo_data['kontoinhaber'] = kontoinhaber
# SEPA Mandat
mandatsreferenz = advo_entity.get('mandatsreferenz')
if mandatsreferenz:
espo_data['mandatsreferenz'] = mandatsreferenz
mandat_vom = advo_entity.get('mandatVom')
if mandat_vom:
# Konvertiere DateTime zu Date (EspoCRM Format: YYYY-MM-DD)
espo_data['mandatVom'] = mandat_vom.split('T')[0] if 'T' in mandat_vom else mandat_vom
logger.debug(f"Mapped to EspoCRM: IBAN={espo_data.get('iban')}")
# Entferne None-Werte (EspoCRM Validierung)
espo_data = {k: v for k, v in espo_data.items() if v is not None}
return espo_data
@staticmethod
def get_changed_fields(espo_entity: Dict[str, Any], advo_entity: Dict[str, Any]) -> List[str]:
"""
Vergleicht zwei Entities und gibt Liste der geänderten Felder zurück
Args:
espo_entity: EspoCRM CBankverbindungen
advo_entity: Advoware Bankverbindung
Returns:
Liste von Feldnamen die unterschiedlich sind
"""
mapped_advo = BankverbindungenMapper.map_advoware_to_cbankverbindungen(advo_entity)
changed = []
compare_fields = [
'bank', 'iban', 'bic', 'kontoNummer', 'blz',
'kontoinhaber', 'mandatsreferenz', 'mandatVom',
'advowareId', 'advowareRowId'
]
for field in compare_fields:
espo_val = espo_entity.get(field)
advo_val = mapped_advo.get(field)
# Normalisiere None und leere Strings
espo_val = espo_val if espo_val else None
advo_val = advo_val if advo_val else None
if espo_val != advo_val:
changed.append(field)
logger.debug(f"Field '{field}' changed: EspoCRM='{espo_val}' vs Advoware='{advo_val}'")
return changed

View File

@@ -0,0 +1,670 @@
"""
Beteiligte Sync Utilities
Hilfsfunktionen für Sync-Operationen:
- Distributed locking via Redis + syncStatus
- Timestamp-Vergleich mit rowId-basierter Änderungserkennung
- Konfliktauflösung (EspoCRM wins)
- EspoCRM In-App Notifications
- Soft-Delete Handling
- Retry mit Exponential Backoff
"""
from typing import Dict, Any, Optional, Tuple, Literal
from datetime import datetime, timedelta
import pytz
from services.exceptions import LockAcquisitionError, SyncError, ValidationError
from services.redis_client import get_redis_client
from services.config import SYNC_CONFIG, get_lock_key, get_retry_delay_seconds
from services.logging_utils import get_service_logger
import redis
# Timestamp-Vergleich Ergebnis-Typen
TimestampResult = Literal["espocrm_newer", "advoware_newer", "conflict", "no_change"]
class BeteiligteSync:
"""Utility-Klasse für Beteiligte-Synchronisation"""
def __init__(self, espocrm_api, redis_client: Optional[redis.Redis] = None, context=None):
self.espocrm = espocrm_api
self.context = context
self.logger = get_service_logger('beteiligte_sync', context)
# Use provided Redis client or get from factory
self.redis = redis_client or get_redis_client(strict=False)
if not self.redis:
self.logger.error(
"⚠️ KRITISCH: Redis nicht verfügbar! "
"Distributed Locking deaktiviert - Race Conditions möglich!"
)
# Import NotificationManager only when needed
from services.notification_utils import NotificationManager
self.notification_manager = NotificationManager(espocrm_api=self.espocrm, context=context)
def _log(self, message: str, level: str = 'info') -> None:
"""Delegate logging to the logger with optional level"""
log_func = getattr(self.logger, level, self.logger.info)
log_func(message)
async def acquire_sync_lock(self, entity_id: str) -> bool:
"""
Atomic distributed lock via Redis + syncStatus update
Args:
entity_id: EspoCRM CBeteiligte ID
Returns:
True wenn Lock erfolgreich, False wenn bereits im Sync
Raises:
SyncError: Bei kritischen Sync-Problemen
"""
try:
# STEP 1: Atomic Redis lock (prevents race conditions)
if self.redis:
lock_key = get_lock_key('cbeteiligte', entity_id)
acquired = self.redis.set(
lock_key,
"locked",
nx=True,
ex=SYNC_CONFIG.lock_ttl_seconds
)
if not acquired:
self.logger.warning(f"Redis lock bereits aktiv für {entity_id}")
return False
else:
self.logger.error(
f"⚠️ WARNUNG: Sync ohne Redis-Lock für {entity_id} - Race Condition möglich!"
)
# STEP 2: Update syncStatus (für UI visibility)
await self.espocrm.update_entity('CBeteiligte', entity_id, {
'syncStatus': 'syncing'
})
self.logger.info(f"Sync-Lock für {entity_id} erworben")
return True
except Exception as e:
self.logger.error(f"Fehler beim Acquire Lock: {e}")
# Clean up Redis lock on error
if self.redis:
try:
lock_key = f"sync_lock:cbeteiligte:{entity_id}"
self.redis.delete(lock_key)
except:
pass
return False
async def release_sync_lock(
self,
entity_id: str,
new_status: str = 'clean',
error_message: Optional[str] = None,
increment_retry: bool = False,
extra_fields: Optional[Dict[str, Any]] = None
) -> None:
"""
Gibt Sync-Lock frei und setzt finalen Status (kombiniert mit extra fields)
Args:
entity_id: EspoCRM CBeteiligte ID
new_status: Neuer syncStatus (clean, failed, conflict, etc.)
error_message: Optional: Fehlermeldung für syncErrorMessage
increment_retry: Ob syncRetryCount erhöht werden soll
extra_fields: Optional: Zusätzliche Felder für EspoCRM update (z.B. betnr)
"""
try:
# EspoCRM datetime format: YYYY-MM-DD HH:MM:SS (keine Timezone!)
now_utc = datetime.now(pytz.UTC)
espo_datetime = now_utc.strftime('%Y-%m-%d %H:%M:%S')
update_data = {
'syncStatus': new_status,
'advowareLastSync': espo_datetime
}
if error_message:
update_data['syncErrorMessage'] = error_message[:2000] # Max. 2000 chars
else:
update_data['syncErrorMessage'] = None
# Handle retry count
if increment_retry:
# Hole aktuellen Retry-Count
entity = await self.espocrm.get_entity('CBeteiligte', entity_id)
current_retry = entity.get('syncRetryCount') or 0
new_retry = current_retry + 1
update_data['syncRetryCount'] = new_retry
# Exponential backoff - berechne nächsten Retry-Zeitpunkt
backoff_minutes = SYNC_CONFIG.retry_backoff_minutes
if new_retry <= len(backoff_minutes):
backoff_min = backoff_minutes[new_retry - 1]
else:
backoff_min = backoff_minutes[-1] # Letzte Backoff-Zeit
next_retry = now_utc + timedelta(minutes=backoff_min)
update_data['syncNextRetry'] = next_retry.strftime('%Y-%m-%d %H:%M:%S')
self.logger.info(
f"Retry {new_retry}/{SYNC_CONFIG.max_retries}, "
f"nächster Versuch in {backoff_min} Minuten"
)
# Check max retries - mark as permanently failed
if new_retry >= SYNC_CONFIG.max_retries:
update_data['syncStatus'] = 'permanently_failed'
# Auto-Reset Timestamp für Wiederherstellung nach 24h
auto_reset_time = now_utc + timedelta(hours=SYNC_CONFIG.auto_reset_hours)
update_data['syncAutoResetAt'] = auto_reset_time.strftime('%Y-%m-%d %H:%M:%S')
await self.send_notification(
entity_id,
'error',
extra_data={
'message': (
f"Sync fehlgeschlagen nach {SYNC_CONFIG.max_retries} Versuchen. "
f"Auto-Reset in {SYNC_CONFIG.auto_reset_hours}h."
)
}
)
self.logger.error(
f"Max retries ({SYNC_CONFIG.max_retries}) erreicht für {entity_id}, "
f"Auto-Reset um {auto_reset_time}"
)
else:
update_data['syncRetryCount'] = 0
update_data['syncNextRetry'] = None
# Merge extra fields (e.g., betnr from create operation)
if extra_fields:
update_data.update(extra_fields)
await self.espocrm.update_entity('CBeteiligte', entity_id, update_data)
self.logger.info(f"Sync-Lock released: {entity_id}{new_status}")
# Release Redis lock
if self.redis:
lock_key = get_lock_key('cbeteiligte', entity_id)
self.redis.delete(lock_key)
except Exception as e:
self.logger.error(f"Fehler beim Release Lock: {e}")
# Ensure Redis lock is released even on error
if self.redis:
try:
lock_key = get_lock_key('cbeteiligte', entity_id)
self.redis.delete(lock_key)
except:
pass
def parse_timestamp(self, ts: Any) -> Optional[datetime]:
"""
Parse various timestamp formats to datetime.
Args:
ts: String, datetime or None
Returns:
datetime object or None
"""
if not ts:
return None
if isinstance(ts, datetime):
return ts
if isinstance(ts, str):
# EspoCRM format: "2026-02-07 14:30:00"
# Advoware format: "2026-02-07T14:30:00" or "2026-02-07T14:30:00Z"
try:
# Remove trailing Z if present
ts = ts.rstrip('Z')
# Try various formats
for fmt in [
'%Y-%m-%d %H:%M:%S',
'%Y-%m-%dT%H:%M:%S',
'%Y-%m-%d',
]:
try:
return datetime.strptime(ts, fmt)
except ValueError:
continue
# Fallback: ISO format
return datetime.fromisoformat(ts)
except Exception as e:
self._log(f"Could not parse timestamp: {ts} - {e}", level='warning')
return None
return None
def compare_entities(
self,
espo_entity: Dict[str, Any],
advo_entity: Dict[str, Any]
) -> TimestampResult:
"""
Vergleicht Änderungen zwischen EspoCRM und Advoware
PRIMÄR: rowId-Vergleich (Advoware rowId ändert sich bei jedem Update - SEHR zuverlässig!)
FALLBACK: Timestamp-Vergleich (wenn rowId nicht verfügbar)
Args:
espo_entity: EspoCRM CBeteiligte
advo_entity: Advoware Beteiligte
Returns:
"espocrm_newer": EspoCRM wurde geändert
"advoware_newer": Advoware wurde geändert
"conflict": Beide wurden geändert
"no_change": Keine Änderungen
"""
# PRIMÄR: rowId-basierte Änderungserkennung (zuverlässiger!)
espo_rowid = espo_entity.get('advowareRowId')
advo_rowid = advo_entity.get('rowId')
last_sync = espo_entity.get('advowareLastSync')
espo_modified = espo_entity.get('modifiedAt')
# Parse timestamps für Initial Sync Check
espo_ts = self.parse_timestamp(espo_modified)
advo_ts = self.parse_timestamp(advo_entity.get('geaendertAm'))
# SPECIAL CASE: Kein lastSync → Initial Sync
if not last_sync:
self._log(f"Initial Sync (kein lastSync) → Vergleiche Timestamps")
# Wenn beide Timestamps vorhanden, vergleiche sie
if espo_ts and advo_ts:
if espo_ts > advo_ts:
self._log(f"Initial Sync: EspoCRM neuer ({espo_ts} > {advo_ts})")
return 'espocrm_newer'
elif advo_ts > espo_ts:
self._log(f"Initial Sync: Advoware neuer ({advo_ts} > {espo_ts})")
return 'advoware_newer'
else:
self._log(f"Initial Sync: Beide gleich alt")
return 'no_change'
# Fallback: Wenn nur einer Timestamp hat, bevorzuge den
if espo_ts and not advo_ts:
return 'espocrm_newer'
if advo_ts and not espo_ts:
return 'advoware_newer'
# Wenn keine Timestamps verfügbar: EspoCRM bevorzugen (default)
self._log(f"Initial Sync: Keine Timestamps verfügbar → EspoCRM bevorzugt")
return 'espocrm_newer'
if espo_rowid and advo_rowid:
# Prüfe ob Advoware geändert wurde (rowId)
advo_changed = (espo_rowid != advo_rowid)
# Prüfe ob EspoCRM auch geändert wurde (seit letztem Sync)
espo_changed = False
if espo_modified:
try:
sync_ts = self.parse_timestamp(last_sync)
if espo_ts and sync_ts:
espo_changed = (espo_ts > sync_ts)
except Exception as e:
self._log(f"Timestamp-Parse-Fehler: {e}", level='debug')
# Konfliktlogik: Beide geändert seit letztem Sync?
if advo_changed and espo_changed:
self._log(f"🚨 KONFLIKT: Beide Seiten geändert seit letztem Sync")
return 'conflict'
elif advo_changed:
self._log(f"Advoware rowId geändert: {espo_rowid[:20] if espo_rowid else 'None'}... → {advo_rowid[:20] if advo_rowid else 'None'}...")
return 'advoware_newer'
elif espo_changed:
self._log(f"EspoCRM neuer (modifiedAt > lastSync)")
return 'espocrm_newer'
else:
# Weder Advoware noch EspoCRM geändert
self._log("Keine Änderungen (rowId identisch)")
return 'no_change'
# FALLBACK: Timestamp-Vergleich (wenn rowId nicht verfügbar)
self._log("rowId nicht verfügbar, fallback auf Timestamp-Vergleich", level='debug')
return self.compare_timestamps(
espo_entity.get('modifiedAt'),
advo_entity.get('geaendertAm'),
espo_entity.get('advowareLastSync')
)
def compare_timestamps(
self,
espo_modified_at: Any,
advo_geaendert_am: Any,
last_sync_ts: Any
) -> TimestampResult:
"""
Vergleicht Timestamps und bestimmt Sync-Richtung (FALLBACK wenn rowId nicht verfügbar)
Args:
espo_modified_at: EspoCRM modifiedAt
advo_geaendert_am: Advoware geaendertAm
last_sync_ts: Letzter Sync (advowareLastSync)
Returns:
"espocrm_newer": EspoCRM wurde nach last_sync geändert und ist neuer
"advoware_newer": Advoware wurde nach last_sync geändert und ist neuer
"conflict": Beide wurden nach last_sync geändert
"no_change": Keine Änderungen seit last_sync
"""
espo_ts = self.parse_timestamp(espo_modified_at)
advo_ts = self.parse_timestamp(advo_geaendert_am)
sync_ts = self.parse_timestamp(last_sync_ts)
# Logging
self._log(
f"Timestamp-Vergleich: EspoCRM={espo_ts}, Advoware={advo_ts}, LastSync={sync_ts}",
level='debug'
)
# Falls kein last_sync → erster Sync, vergleiche direkt
if not sync_ts:
if not espo_ts or not advo_ts:
return "no_change"
if espo_ts > advo_ts:
return "espocrm_newer"
elif advo_ts > espo_ts:
return "advoware_newer"
else:
return "no_change"
# Check ob seit last_sync Änderungen
espo_changed = espo_ts and espo_ts > sync_ts
advo_changed = advo_ts and advo_ts > sync_ts
if espo_changed and advo_changed:
# Beide geändert seit last_sync → Konflikt
return "conflict"
elif espo_changed:
# Nur EspoCRM geändert
return "espocrm_newer" if (not advo_ts or espo_ts > advo_ts) else "conflict"
elif advo_changed:
# Nur Advoware geändert
return "advoware_newer"
else:
# Keine Änderungen
return "no_change"
def merge_for_advoware_put(
self,
advo_entity: Dict[str, Any],
espo_entity: Dict[str, Any],
mapper
) -> Dict[str, Any]:
"""
Merged EspoCRM updates mit Advoware entity für PUT operation
Advoware benötigt vollständige Objekte für PUT (Read-Modify-Write pattern).
Diese Funktion merged die gemappten EspoCRM-Updates in das bestehende
Advoware-Objekt.
Args:
advo_entity: Aktuelles Advoware entity (vollständiges Objekt)
espo_entity: EspoCRM entity mit Updates
mapper: BeteiligteMapper instance
Returns:
Merged dict für Advoware PUT
"""
# Map EspoCRM → Advoware (nur Stammdaten)
advo_updates = mapper.map_cbeteiligte_to_advoware(espo_entity)
# Merge: Advoware entity als Base, überschreibe mit EspoCRM updates
merged = {**advo_entity, **advo_updates}
# Logging
self._log(
f"📝 Merge: {len(advo_updates)} Stammdaten-Felder → {len(merged)} Gesamt-Felder",
level='info'
)
self._log(
f" Gesynct: {', '.join(advo_updates.keys())}",
level='debug'
)
return merged
async def send_notification(
self,
entity_id: str,
notification_type: Literal["conflict", "deleted", "error"],
extra_data: Optional[Dict[str, Any]] = None
) -> None:
"""
Sendet EspoCRM Notification via NotificationManager
Args:
entity_id: CBeteiligte Entity ID
notification_type: "conflict", "deleted" oder "error"
extra_data: Zusätzliche Daten für Nachricht
"""
try:
# Hole Entity-Daten
entity = await self.espocrm.get_entity('CBeteiligte', entity_id)
name = entity.get('name', 'Unbekannt')
betnr = entity.get('betnr')
# Map notification_type zu action_type
if notification_type == "conflict":
action_type = 'sync_conflict'
details = {
'message': f"Sync-Konflikt bei Beteiligten '{name}' (betNr: {betnr})",
'description': (
f"EspoCRM hat Vorrang - Änderungen wurden nach Advoware übertragen.\n\n"
f"Bitte prüfen Sie die Details und stellen Sie sicher, dass die Daten korrekt sind."
),
'entity_name': name,
'betnr': betnr,
'priority': 'Normal'
}
elif notification_type == "deleted":
deleted_at = entity.get('advowareDeletedAt', 'unbekannt')
action_type = 'entity_deleted_in_source'
details = {
'message': f"Beteiligter '{name}' wurde in Advoware gelöscht",
'description': (
f"Der Beteiligte '{name}' (betNr: {betnr}) wurde am {deleted_at} "
f"in Advoware gelöscht.\n\n"
f"Der Datensatz wurde in EspoCRM markiert, aber nicht gelöscht. "
f"Bitte prüfen Sie, ob dies beabsichtigt war."
),
'entity_name': name,
'betnr': betnr,
'deleted_at': deleted_at,
'priority': 'High'
}
else: # error
action_type = 'general_manual_action'
details = {
'message': f"Benachrichtigung für Beteiligten '{name}'",
'entity_name': name,
'betnr': betnr
}
# Merge extra_data if provided
if extra_data:
details.update(extra_data)
# Sende via NotificationManager
await self.notification_manager.notify_manual_action_required(
entity_type='CBeteiligte',
entity_id=entity_id,
action_type=action_type,
details=details,
create_task=True
)
self._log(f"Notification via NotificationManager gesendet: {notification_type} für {entity_id}")
except Exception as e:
self._log(f"Fehler beim Senden der Notification: {e}", level='error')
async def handle_advoware_deleted(
self,
entity_id: str,
error_details: str
) -> None:
"""
Behandelt Fall dass Beteiligter in Advoware gelöscht wurde (404)
Args:
entity_id: CBeteiligte Entity ID
error_details: Fehlerdetails von Advoware API
"""
try:
now = datetime.now(pytz.UTC).isoformat()
# Update Entity: Soft-Delete Flag
await self.espocrm.update_entity('CBeteiligte', entity_id, {
'syncStatus': 'deleted_in_advoware',
'advowareDeletedAt': now,
'syncErrorMessage': f"Beteiligter existiert nicht mehr in Advoware. {error_details}"
})
self._log(f"Entity {entity_id} als deleted_in_advoware markiert")
# Sende Notification
await self.send_notification(entity_id, 'deleted')
except Exception as e:
self._log(f"Fehler beim Handle Deleted: {e}", level='error')
async def validate_sync_result(
self,
entity_id: str,
betnr: int,
mapper,
direction: str = 'to_advoware'
) -> Tuple[bool, Optional[str]]:
"""
Validiert Sync-Ergebnis durch Round-Trip Verification
Args:
entity_id: EspoCRM CBeteiligte ID
betnr: Advoware betNr
mapper: BeteiligteMapper instance
direction: 'to_advoware' oder 'to_espocrm'
Returns:
(success: bool, error_message: Optional[str])
"""
try:
self._log(f"🔍 Validiere Sync-Ergebnis (direction={direction})...", level='debug')
# Lade beide Entities erneut
espo_entity = await self.espocrm.get_entity('CBeteiligte', entity_id)
from services.advoware import AdvowareAPI
advoware_api = AdvowareAPI(self.context)
advo_result = await advoware_api.api_call(f'api/v1/advonet/Beteiligte/{betnr}', method='GET')
if isinstance(advo_result, list):
advo_entity = advo_result[0] if advo_result else None
else:
advo_entity = advo_result
if not advo_entity:
return False, f"Advoware Entity {betnr} nicht gefunden nach Sync"
# Validiere Stammdaten
critical_fields = ['name', 'rechtsform']
differences = []
if direction == 'to_advoware':
# EspoCRM → Advoware: Prüfe ob Advoware die EspoCRM-Werte hat
advo_mapped = mapper.map_cbeteiligte_to_advoware(espo_entity)
for field in critical_fields:
espo_val = advo_mapped.get(field)
advo_val = advo_entity.get(field)
if espo_val != advo_val:
differences.append(f"{field}: expected '{espo_val}', got '{advo_val}'")
elif direction == 'to_espocrm':
# Advoware → EspoCRM: Prüfe ob EspoCRM die Advoware-Werte hat
espo_mapped = mapper.map_advoware_to_cbeteiligte(advo_entity)
for field in critical_fields:
advo_val = espo_mapped.get(field)
espo_val = espo_entity.get(field)
if advo_val != espo_val:
differences.append(f"{field}: expected '{advo_val}', got '{espo_val}'")
if differences:
error_msg = f"Validation failed: {', '.join(differences)}"
self._log(f"{error_msg}", level='error')
return False, error_msg
self._log(f"✅ Validation erfolgreich", level='debug')
return True, None
except Exception as e:
self._log(f"⚠️ Validation error: {e}", level='error')
return False, f"Validation exception: {str(e)}"
async def resolve_conflict_espocrm_wins(
self,
entity_id: str,
espo_entity: Dict[str, Any],
advo_entity: Dict[str, Any],
conflict_details: str,
extra_fields: Optional[Dict[str, Any]] = None
) -> None:
"""
Löst Konflikt auf: EspoCRM wins (überschreibt Advoware)
Args:
entity_id: CBeteiligte Entity ID
espo_entity: EspoCRM Entity-Daten
advo_entity: Advoware Entity-Daten
conflict_details: Details zum Konflikt
extra_fields: Zusätzliche Felder (z.B. advowareRowId)
"""
try:
# EspoCRM datetime format
now_utc = datetime.now(pytz.UTC)
espo_datetime = now_utc.strftime('%Y-%m-%d %H:%M:%S')
# Markiere als gelöst mit Konflikt-Info
update_data = {
'syncStatus': 'clean', # Gelöst!
'advowareLastSync': espo_datetime,
'syncErrorMessage': f'Konflikt: {conflict_details}',
'syncRetryCount': 0
}
# Merge extra fields (z.B. advowareRowId)
if extra_fields:
update_data.update(extra_fields)
await self.espocrm.update_entity('CBeteiligte', entity_id, update_data)
self._log(f"Konflikt gelöst für {entity_id}: EspoCRM wins")
# Sende Notification
await self.send_notification(entity_id, 'conflict', {
'details': conflict_details
})
except Exception as e:
self._log(f"Fehler beim Resolve Conflict: {e}", level='error')

338
services/config.py Normal file
View File

@@ -0,0 +1,338 @@
"""
Zentrale Konfiguration für BitByLaw Integration
Alle Magic Numbers und Strings sind hier zentralisiert.
"""
from typing import List, Dict
from dataclasses import dataclass
import os
# ========== Sync Configuration ==========
@dataclass
class SyncConfig:
"""Konfiguration für Sync-Operationen"""
# Retry-Konfiguration
max_retries: int = 5
"""Maximale Anzahl von Retry-Versuchen"""
retry_backoff_minutes: List[int] = None
"""Exponential Backoff in Minuten: [1, 5, 15, 60, 240]"""
auto_reset_hours: int = 24
"""Auto-Reset für permanently_failed Entities (in Stunden)"""
# Lock-Konfiguration
lock_ttl_seconds: int = 900 # 15 Minuten
"""TTL für distributed locks (verhindert Deadlocks)"""
lock_prefix: str = "sync_lock"
"""Prefix für Redis Lock Keys"""
# Validation
validate_before_sync: bool = True
"""Validiere Entities vor dem Sync (empfohlen)"""
# Change Detection
use_rowid_change_detection: bool = True
"""Nutze rowId für Change Detection (Advoware)"""
def __post_init__(self):
if self.retry_backoff_minutes is None:
# Default exponential backoff: 1, 5, 15, 60, 240 Minuten
self.retry_backoff_minutes = [1, 5, 15, 60, 240]
# Singleton Instance
SYNC_CONFIG = SyncConfig()
# ========== API Configuration ==========
@dataclass
class APIConfig:
"""API-spezifische Konfiguration"""
# Timeouts
default_timeout_seconds: int = 30
"""Default Timeout für API-Calls"""
long_running_timeout_seconds: int = 120
"""Timeout für lange Operations (z.B. Uploads)"""
# Retry
max_api_retries: int = 3
"""Anzahl Retries bei API-Fehlern"""
retry_status_codes: List[int] = None
"""HTTP Status Codes die Retry auslösen"""
# Rate Limiting
rate_limit_enabled: bool = True
"""Aktiviere Rate Limiting"""
rate_limit_calls_per_minute: int = 60
"""Max. API-Calls pro Minute"""
def __post_init__(self):
if self.retry_status_codes is None:
# Retry bei: 408 (Timeout), 429 (Rate Limit), 500, 502, 503, 504
self.retry_status_codes = [408, 429, 500, 502, 503, 504]
API_CONFIG = APIConfig()
# ========== Advoware Configuration ==========
@dataclass
class AdvowareConfig:
"""Advoware-spezifische Konfiguration"""
# Token Management
token_lifetime_minutes: int = 55
"""Token-Lifetime (tatsächlich 60min, aber 5min Puffer)"""
token_cache_key: str = "advoware_access_token"
"""Redis Key für Token Cache"""
token_timestamp_key: str = "advoware_token_timestamp"
"""Redis Key für Token Timestamp"""
# Auth
auth_url: str = "https://security.advo-net.net/api/v1/Token"
"""Advoware Auth-Endpoint"""
product_id: int = 64
"""Advoware Product ID"""
# Field Mapping
readonly_fields: List[str] = None
"""Felder die nicht via PUT geändert werden können"""
def __post_init__(self):
if self.readonly_fields is None:
# Diese Felder können nicht via PUT geändert werden
self.readonly_fields = [
'betNr', 'rowId', 'kommKz', # Kommunikation: kommKz ist read-only!
'handelsRegisterNummer', 'registergericht' # Werden ignoriert von API
]
ADVOWARE_CONFIG = AdvowareConfig()
# ========== EspoCRM Configuration ==========
@dataclass
class EspoCRMConfig:
"""EspoCRM-spezifische Konfiguration"""
# API
default_page_size: int = 50
"""Default Seitengröße für Listen-Abfragen"""
max_page_size: int = 200
"""Maximale Seitengröße"""
# Sync Status Fields
sync_status_field: str = "syncStatus"
"""Feldname für Sync-Status"""
sync_error_field: str = "syncErrorMessage"
"""Feldname für Sync-Fehler"""
sync_retry_field: str = "syncRetryCount"
"""Feldname für Retry-Counter"""
# Notifications
notification_enabled: bool = True
"""In-App Notifications aktivieren"""
notification_user_id: str = "1"
"""User-ID für Notifications (Marvin)"""
ESPOCRM_CONFIG = EspoCRMConfig()
# ========== Redis Configuration ==========
@dataclass
class RedisConfig:
"""Redis-spezifische Konfiguration"""
# Connection
host: str = "localhost"
port: int = 6379
db: int = 1
timeout_seconds: int = 5
max_connections: int = 50
# Behavior
decode_responses: bool = True
"""Auto-decode bytes zu strings"""
health_check_interval: int = 30
"""Health-Check Interval in Sekunden"""
# Keys
key_prefix: str = "bitbylaw"
"""Prefix für alle Redis Keys"""
def get_key(self, key: str) -> str:
"""Gibt vollen Redis Key mit Prefix zurück"""
return f"{self.key_prefix}:{key}"
@classmethod
def from_env(cls) -> 'RedisConfig':
"""Lädt Redis-Config aus Environment Variables"""
return cls(
host=os.getenv('REDIS_HOST', 'localhost'),
port=int(os.getenv('REDIS_PORT', '6379')),
db=int(os.getenv('REDIS_DB_ADVOWARE_CACHE', '1')),
timeout_seconds=int(os.getenv('REDIS_TIMEOUT_SECONDS', '5')),
max_connections=int(os.getenv('REDIS_MAX_CONNECTIONS', '50'))
)
REDIS_CONFIG = RedisConfig.from_env()
# ========== Logging Configuration ==========
@dataclass
class LoggingConfig:
"""Logging-Konfiguration"""
# Levels
default_level: str = "INFO"
"""Default Log-Level"""
api_level: str = "INFO"
"""Log-Level für API-Calls"""
sync_level: str = "INFO"
"""Log-Level für Sync-Operations"""
# Format
log_format: str = "[{timestamp}] {level} {logger}: {message}"
"""Log-Format"""
include_context: bool = True
"""Motia FlowContext in Logs einbinden"""
# Performance
log_api_timings: bool = True
"""API Call Timings loggen"""
log_sync_duration: bool = True
"""Sync-Dauer loggen"""
LOGGING_CONFIG = LoggingConfig()
# ========== Calendar Sync Configuration ==========
@dataclass
class CalendarSyncConfig:
"""Konfiguration für Google Calendar Sync"""
# Sync Window
sync_days_past: int = 7
"""Tage in die Vergangenheit syncen"""
sync_days_future: int = 90
"""Tage in die Zukunft syncen"""
# Cron
cron_schedule: str = "0 */15 * * * *"
"""Cron-Schedule (jede 15 Minuten)"""
# Batch Size
batch_size: int = 10
"""Anzahl Mitarbeiter pro Batch"""
CALENDAR_SYNC_CONFIG = CalendarSyncConfig()
# ========== Feature Flags ==========
@dataclass
class FeatureFlags:
"""Feature Flags für schrittweises Rollout"""
# Validation
strict_validation: bool = True
"""Strenge Validierung mit Pydantic"""
# Sync Features
kommunikation_sync_enabled: bool = False
"""Kommunikation-Sync aktivieren (noch in Entwicklung)"""
document_sync_enabled: bool = False
"""Document-Sync aktivieren (noch in Entwicklung)"""
# Advanced Features
parallel_sync_enabled: bool = False
"""Parallele Sync-Operations (experimentell)"""
auto_conflict_resolution: bool = False
"""Automatische Konfliktauflösung (experimentell)"""
# Debug
debug_mode: bool = False
"""Debug-Modus (mehr Logging, langsamer)"""
FEATURE_FLAGS = FeatureFlags()
# ========== Helper Functions ==========
def get_retry_delay_seconds(attempt: int) -> int:
"""
Gibt Retry-Delay in Sekunden für gegebenen Versuch zurück.
Args:
attempt: Versuchs-Nummer (0-indexed)
Returns:
Delay in Sekunden
"""
backoff_minutes = SYNC_CONFIG.retry_backoff_minutes
if attempt < len(backoff_minutes):
return backoff_minutes[attempt] * 60
return backoff_minutes[-1] * 60
def get_lock_key(entity_type: str, entity_id: str) -> str:
"""
Erzeugt Redis Lock-Key für Entity.
Args:
entity_type: Entity-Typ (z.B. 'cbeteiligte')
entity_id: Entity-ID
Returns:
Redis Key
"""
return f"{SYNC_CONFIG.lock_prefix}:{entity_type.lower()}:{entity_id}"
def is_retryable_status_code(status_code: int) -> bool:
"""
Prüft ob HTTP Status Code Retry auslösen soll.
Args:
status_code: HTTP Status Code
Returns:
True wenn retryable
"""
return status_code in API_CONFIG.retry_status_codes

View File

@@ -0,0 +1,622 @@
"""
Document Sync Utilities
Utility functions for document synchronization with xAI:
- Distributed locking via Redis + syncStatus
- 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 urllib.parse import unquote
from services.sync_utils_base import BaseSyncUtils
from services.models import FileStatus, XAISyncStatus
# Max retry before permanent failure
MAX_SYNC_RETRIES = 5
# Retry backoff: Wartezeit zwischen Retries (in Minuten)
RETRY_BACKOFF_MINUTES = [1, 5, 15, 60, 240] # 1min, 5min, 15min, 1h, 4h
# Legacy file status values (for backward compatibility)
# These are old German and English status values that may still exist in the database
LEGACY_NEW_STATUS_VALUES = {'neu', 'Neu', 'New'}
LEGACY_CHANGED_STATUS_VALUES = {'geändert', 'Geändert', 'Changed'}
LEGACY_SYNCED_STATUS_VALUES = {'synced', 'Synced', 'synchronized', 'Synchronized'}
class DocumentSync(BaseSyncUtils):
"""Utility class for document synchronization with xAI"""
def _get_lock_key(self, entity_id: str) -> str:
"""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:
"""
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
"""
try:
# STEP 1: Atomic Redis lock (prevents race conditions)
lock_key = self._get_lock_key(entity_id)
if not self._acquire_redis_lock(lock_key):
self._log(f"Redis lock bereits aktiv für {entity_type} {entity_id}", level='warn')
return False
# STEP 2: Update xaiSyncStatus to pending_sync
try:
await self.espocrm.update_entity(entity_type, entity_id, {
'xaiSyncStatus': XAISyncStatus.PENDING_SYNC.value
})
except Exception as e:
self._log(f"Could not set xaiSyncStatus: {e}", level='debug')
self._log(f"Sync-Lock für {entity_type} {entity_id} erworben")
return True
except Exception as e:
self._log(f"Fehler beim Acquire Lock: {e}", level='error')
# Clean up Redis lock on error
lock_key = self._get_lock_key(entity_id)
self._release_redis_lock(lock_key)
return False
async def release_sync_lock(
self,
entity_id: str,
success: bool = True,
error_message: Optional[str] = None,
extra_fields: Optional[Dict[str, Any]] = None,
entity_type: str = 'CDokumente'
) -> None:
"""
Gibt Sync-Lock frei und setzt finalen Status
Args:
entity_id: EspoCRM Document ID
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 = {}
# Set xaiSyncStatus: clean on success, failed on error
try:
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 # Fields may not exist
# Merge extra fields (z.B. xaiFileId, xaiCollections)
if extra_fields:
update_data.update(extra_fields)
if update_data:
await self.espocrm.update_entity(entity_type, entity_id, update_data)
self._log(f"Sync-Lock released: {entity_type} {entity_id}{'success' if success else 'failed'}")
# Release Redis lock
lock_key = self._get_lock_key(entity_id)
self._release_redis_lock(lock_key)
except Exception as e:
self._log(f"Fehler beim Release Lock: {e}", level='error')
# Ensure Redis lock is released even on error
lock_key = self._get_lock_key(entity_id)
self._release_redis_lock(lock_key)
async def should_sync_to_xai(
self,
document: Dict[str, Any],
entity_type: str = 'CDokumente'
) -> Tuple[bool, List[str], str]:
"""
Decide if a document needs to be synchronized to xAI.
Checks:
1. File status field ("new", "changed")
2. Hash values for change detection
3. Related entities with xAI collections
Args:
document: Complete document entity from EspoCRM
Returns:
Tuple[bool, List[str], str]:
- 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-relevant fields
xai_file_id = document.get('xaiFileId')
xai_collections = document.get('xaiCollections') or []
xai_sync_status = document.get('xaiSyncStatus')
# 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 at last xAI sync
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'}")
self._log(f" fileStatus: {datei_status or 'N/A'}")
self._log(f" MD5: {file_md5[:16] if file_md5 else 'N/A'}...")
self._log(f" SHA: {file_sha[:16] if file_sha else 'N/A'}...")
self._log(f" xaiSyncedHash: {xai_synced_hash[:16] if xai_synced_hash else 'N/A'}...")
# Determine target collections from relations (CDokumente -> linked entities)
target_collections = await self._get_required_collections_from_relations(
doc_id,
entity_type=entity_type
)
# 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("⏭️ No xAI sync needed: No related entities with xAI collections")
return (False, [], "No linked entities with xAI collections")
# ═══════════════════════════════════════════════════════════════
# PRIORITY CHECK 1: xaiSyncStatus="unclean" -> document was changed
# ═══════════════════════════════════════════════════════════════
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" or "changed"
# ═══════════════════════════════════════════════════════════════
# Check for standard enum values and legacy values
is_new = (datei_status == FileStatus.NEW.value or datei_status in LEGACY_NEW_STATUS_VALUES)
is_changed = (datei_status == FileStatus.CHANGED.value or datei_status in LEGACY_CHANGED_STATUS_VALUES)
if is_new or is_changed:
self._log(f"🆕 fileStatus: '{datei_status}' → xAI sync REQUIRED")
if target_collections:
return (True, target_collections, f"fileStatus: {datei_status}")
else:
# 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")
# ═══════════════════════════════════════════════════════════════
# CASE 1: Document is already in xAI AND collections are set
# ═══════════════════════════════════════════════════════════════
if xai_file_id:
self._log(f"✅ Document already synced to xAI with {len(target_collections)} collection(s)")
# 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 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 identical - no change")
else:
self._log(f"⚠️ No hash values available for comparison")
return (False, target_collections, "Already synced, no change detected")
# ═══════════════════════════════════════════════════════════════
# CASE 2: Document has xaiFileId but collections is empty/None
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
# CASE 3: Collections present but no status/hash trigger
# ═══════════════════════════════════════════════════════════════
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,
document_id: str,
entity_type: str = 'Document'
) -> List[str]:
"""
Determine all xAI collection IDs of CAIKnowledge entities linked to this document.
Checks CAIKnowledgeCDokumente junction table:
- Status 'active' + datenbankId: Returns collection ID
- Status 'new': Returns "NEW:{knowledge_id}" marker (collection must be created first)
- Other statuses (paused, deactivated): Skips
Args:
document_id: Document ID
entity_type: Entity type (e.g., 'CDokumente')
Returns:
List of collection IDs or markers:
- Normal IDs: "abc123..." (existing collections)
- New markers: "NEW:kb-id..." (collection needs to be created via knowledge sync)
"""
collections = set()
self._log(f"🔍 Checking relations of {entity_type} {document_id}...")
# ═══════════════════════════════════════════════════════════════
# SPECIAL HANDLING: CAIKnowledge via Junction Table
# ═══════════════════════════════════════════════════════════════
try:
junction_entries = await self.espocrm.get_junction_entries(
'CAIKnowledgeCDokumente',
'cDokumenteId',
document_id
)
if junction_entries:
self._log(f" 📋 Found {len(junction_entries)} CAIKnowledge link(s)")
for junction in junction_entries:
knowledge_id = junction.get('cAIKnowledgeId')
if not knowledge_id:
continue
try:
knowledge = await self.espocrm.get_entity('CAIKnowledge', knowledge_id)
activation_status = knowledge.get('aktivierungsstatus')
collection_id = knowledge.get('datenbankId')
if activation_status == 'active' and collection_id:
# Existing collection - use it
collections.add(collection_id)
self._log(f" ✅ CAIKnowledge {knowledge_id}: {collection_id} (active)")
elif activation_status == 'new':
# Collection doesn't exist yet - return special marker
# Format: "NEW:{knowledge_id}" signals to caller: trigger knowledge sync first
collections.add(f"NEW:{knowledge_id}")
self._log(f" 🆕 CAIKnowledge {knowledge_id}: status='new' → collection must be created first")
else:
self._log(f" ⏭️ CAIKnowledge {knowledge_id}: status={activation_status}, datenbankId={collection_id or 'N/A'}")
except Exception as e:
self._log(f" ⚠️ Failed to load CAIKnowledge {knowledge_id}: {e}", level='warn')
except Exception as e:
self._log(f" ⚠️ Failed to check CAIKnowledge junction: {e}", level='warn')
result = list(collections)
self._log(f"📊 Gesamt: {len(result)} eindeutige Collection(s) gefunden")
return result
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
- size: Dateigröße in Bytes
"""
try:
# Hole vollständiges Document
doc = await self.espocrm.get_entity(entity_type, document_id)
# EspoCRM Documents können Files auf verschiedene Arten speichern:
# CDokumente: dokumentId/dokumentName (Custom Entity)
# Document: fileId/fileName ODER attachmentsIds
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
# 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')
# URL-decode filename (fixes special chars like §, ä, ö, ü, etc.)
# EspoCRM stores filenames URL-encoded: %C2%A7 → §
final_filename = unquote(final_filename)
return {
'attachment_id': attachment_id,
'download_url': f"/api/v1/Attachment/file/{attachment_id}",
'filename': final_filename,
'mime_type': attachment.get('type', 'application/octet-stream'),
'size': attachment.get('size', 0)
}
except Exception as e:
self._log(f"❌ Fehler beim Laden von Download-Info: {e}", level='error')
return None
async def generate_thumbnail(self, file_path: str, mime_type: str, max_width: int = 600, max_height: int = 800) -> Optional[bytes]:
"""
Generiert Vorschaubild (Preview) für ein Document im WebP-Format
Unterstützt:
- PDF: Erste Seite als Bild
- DOCX/DOC: Konvertierung zu PDF, dann erste Seite
- Images: Resize auf Preview-Größe
- Andere: Platzhalter-Icon basierend auf MIME-Type
Args:
file_path: Pfad zur Datei (lokal)
mime_type: MIME-Type des Documents
max_width: Maximale Breite (default: 600px)
max_height: Maximale Höhe (default: 800px)
Returns:
Preview als WebP bytes oder None bei Fehler
"""
self._log(f"🖼️ Preview-Generierung für {mime_type} (max: {max_width}x{max_height})")
try:
from PIL import Image
import io
thumbnail = None
# PDF-Handling
if mime_type == 'application/pdf':
try:
from pdf2image import convert_from_path
self._log(" Converting PDF page 1 to image...")
images = convert_from_path(file_path, first_page=1, last_page=1, dpi=150)
if images:
thumbnail = images[0]
except ImportError:
self._log("⚠️ pdf2image nicht installiert - überspringe PDF-Preview", level='warn')
return None
except Exception as e:
self._log(f"⚠️ PDF-Konvertierung fehlgeschlagen: {e}", level='warn')
return None
# DOCX/DOC-Handling
elif mime_type in ['application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword']:
try:
import tempfile
import os
from docx2pdf import convert
from pdf2image import convert_from_path
self._log(" Converting DOCX → PDF → Image...")
# Temporäres PDF erstellen
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
pdf_path = tmp.name
# DOCX → PDF (benötigt LibreOffice)
convert(file_path, pdf_path)
# PDF → Image
images = convert_from_path(pdf_path, first_page=1, last_page=1, dpi=150)
if images:
thumbnail = images[0]
# Cleanup
os.remove(pdf_path)
except ImportError:
self._log("⚠️ docx2pdf nicht installiert - überspringe DOCX-Preview", level='warn')
return None
except Exception as e:
self._log(f"⚠️ DOCX-Konvertierung fehlgeschlagen: {e}", level='warn')
return None
# Image-Handling
elif mime_type.startswith('image/'):
try:
self._log(" Processing image file...")
thumbnail = Image.open(file_path)
except Exception as e:
self._log(f"⚠️ Image-Laden fehlgeschlagen: {e}", level='warn')
return None
else:
self._log(f"⚠️ Keine Preview-Generierung für MIME-Type: {mime_type}", level='warn')
return None
if not thumbnail:
return None
# Resize auf max dimensions (behält Aspect Ratio)
thumbnail.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
# Convert zu WebP bytes
buffer = io.BytesIO()
thumbnail.save(buffer, format='WEBP', quality=85)
webp_bytes = buffer.getvalue()
self._log(f"✅ Preview generiert: {len(webp_bytes)} bytes WebP")
return webp_bytes
except Exception as e:
self._log(f"❌ Fehler bei Preview-Generierung: {e}", level='error')
import traceback
self._log(traceback.format_exc(), level='debug')
return None
async def update_sync_metadata(
self,
document_id: str,
xai_file_id: Optional[str] = None,
collection_ids: Optional[List[str]] = None,
file_hash: Optional[str] = None,
preview_data: Optional[bytes] = None,
reset_file_status: bool = False,
entity_type: str = 'CDokumente'
) -> None:
"""
Updated Document-Metadaten nach erfolgreichem xAI-Sync oder Preview-Generierung
Args:
document_id: EspoCRM Document ID
xai_file_id: xAI File ID (optional - setzt nur wenn vorhanden)
collection_ids: Liste der xAI Collection IDs (optional)
file_hash: MD5/SHA Hash des gesyncten Files
preview_data: Vorschaubild (WebP) als bytes
reset_file_status: Ob fileStatus/dateiStatus zurückgesetzt werden soll
entity_type: Entity-Type (CDokumente oder Document)
"""
try:
update_data = {}
# Nur xAI-Felder updaten wenn vorhanden
if 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
# fileStatus auf "unchanged" setzen wenn Dokument verarbeitet/clean ist
if reset_file_status:
if entity_type == 'CDokumente':
update_data['fileStatus'] = 'unchanged'
else:
# Document Entity hat kein fileStatus, nur dateiStatus
update_data['dateiStatus'] = 'unchanged'
# xaiSyncStatus auf "clean" setzen wenn xAI-Sync erfolgreich war
if xai_file_id:
update_data['xaiSyncStatus'] = 'clean'
# Hash speichern für zukünftige Change Detection
if file_hash:
update_data['xaiSyncedHash'] = file_hash
# Preview als Attachment hochladen (falls vorhanden)
if 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(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, 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 to {entity_type} ({len(preview_data)} bytes)...")
# EspoCRM erwartet base64-encoded file im Format: data:mime/type;base64,xxxxx
import base64
import aiohttp
# 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')
# Don't raise - Preview ist optional, Sync sollte trotzdem erfolgreich sein

731
services/espocrm.py Normal file
View File

@@ -0,0 +1,731 @@
"""EspoCRM API client for Motia III"""
import aiohttp
import asyncio
import logging
import time
from typing import Optional, Dict, Any, List
import os
from services.exceptions import (
EspoCRMAPIError,
EspoCRMAuthError,
EspoCRMTimeoutError,
RetryableError,
ValidationError
)
from services.redis_client import get_redis_client
from services.config import ESPOCRM_CONFIG, API_CONFIG
from services.logging_utils import get_service_logger
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)
"""
def __init__(self, context=None):
"""
Initialize EspoCRM API client.
Args:
context: Motia FlowContext for logging (optional)
"""
self.context = context
self.logger = get_service_logger('espocrm', context)
self.logger.debug("EspoCRMAPI initializing")
# 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', str(API_CONFIG.default_timeout_seconds)))
if not self.api_key:
raise EspoCRMAuthError("ESPOCRM_API_KEY not configured in environment")
self.logger.info(f"EspoCRM API initialized with base URL: {self.api_base_url}")
self._session: Optional[aiohttp.ClientSession] = None
self._entity_defs_cache: Dict[str, Dict[str, Any]] = {}
self._entity_defs_cache_ttl_seconds = int(os.getenv('ESPOCRM_METADATA_TTL_SECONDS', '300'))
# Metadata cache (complete metadata loaded once)
self._metadata_cache: Optional[Dict[str, Any]] = None
self._metadata_cache_ts: float = 0
# Optional Redis for caching/rate limiting (centralized)
self.redis_client = get_redis_client(strict=False)
if self.redis_client:
self.logger.info("Connected to Redis for EspoCRM operations")
else:
self.logger.warning("⚠️ Redis unavailable - caching disabled")
def _log(self, message: str, level: str = 'info') -> None:
"""Delegate to IntegrationLogger with optional level"""
log_func = getattr(self.logger, level, self.logger.info)
log_func(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 _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
async def get_metadata(self) -> Dict[str, Any]:
"""
Get complete EspoCRM metadata (cached).
Loads once and caches for TTL duration.
Much faster than individual entity def calls.
Returns:
Complete metadata dict with entityDefs, clientDefs, etc.
"""
now = time.monotonic()
# Return cached if still valid
if (self._metadata_cache is not None and
(now - self._metadata_cache_ts) < self._entity_defs_cache_ttl_seconds):
return self._metadata_cache
# Load fresh metadata
try:
self._log("📥 Loading complete EspoCRM metadata...", level='debug')
metadata = await self.api_call("/Metadata", method='GET')
if not isinstance(metadata, dict):
self._log("⚠️ Metadata response is not a dict, using empty", level='warn')
metadata = {}
# Cache it
self._metadata_cache = metadata
self._metadata_cache_ts = now
entity_count = len(metadata.get('entityDefs', {}))
self._log(f"✅ Metadata cached: {entity_count} entity definitions", level='debug')
return metadata
except Exception as e:
self._log(f"❌ Failed to load metadata: {e}", level='error')
# Return empty dict as fallback
return {}
async def get_entity_def(self, entity_type: str) -> Dict[str, Any]:
"""
Get entity definition for a specific entity type (cached via metadata).
Uses complete metadata cache - much faster and correct API usage.
Args:
entity_type: Entity type (e.g., 'Document', 'CDokumente', 'Account')
Returns:
Entity definition dict with fields, links, etc.
"""
try:
metadata = await self.get_metadata()
entity_defs = metadata.get('entityDefs', {})
if not isinstance(entity_defs, dict):
self._log(f"⚠️ entityDefs is not a dict for {entity_type}", level='warn')
return {}
entity_def = entity_defs.get(entity_type, {})
if not entity_def:
self._log(f"⚠️ No entity definition found for '{entity_type}'", level='debug')
return entity_def
except Exception as e:
self._log(f"⚠️ Could not load entity def for {entity_type}: {e}", level='warn')
return {}
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:
EspoCRMAuthError: Authentication failed
EspoCRMTimeoutError: Request timed out
EspoCRMAPIError: Other 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
)
session = await self._get_session()
try:
with self.logger.api_call(endpoint, method):
async with session.request(
method,
url,
headers=headers,
params=params,
json=json_data,
timeout=effective_timeout
) as response:
# Handle errors
if response.status == 401:
raise EspoCRMAuthError(
"Authentication failed - check API key",
status_code=401
)
elif response.status == 403:
raise EspoCRMAPIError(
"Access forbidden",
status_code=403
)
elif response.status == 404:
raise EspoCRMAPIError(
f"Resource not found: {endpoint}",
status_code=404
)
elif response.status >= 500:
error_text = await response.text()
raise RetryableError(
f"Server error {response.status}: {error_text}"
)
elif response.status >= 400:
error_text = await response.text()
raise EspoCRMAPIError(
f"API error {response.status}: {error_text}",
status_code=response.status,
response_body=error_text
)
# Parse response
if response.content_type == 'application/json':
result = await response.json()
return result
else:
# For DELETE or other non-JSON responses
return None
except asyncio.TimeoutError:
raise EspoCRMTimeoutError(
f"Request timed out after {effective_timeout.total}s",
status_code=408
)
except aiohttp.ClientError as e:
self.logger.error(f"API call failed: {e}")
raise EspoCRMAPIError(f"Request failed: {str(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 list_related(
self,
entity_type: str,
entity_id: str,
link: str,
where: Optional[List[Dict]] = None,
select: Optional[str] = None,
order_by: Optional[str] = None,
order: Optional[str] = None,
offset: int = 0,
max_size: int = 50
) -> Dict[str, Any]:
params = {
'offset': offset,
'maxSize': max_size
}
if where:
import json
params['where'] = where if isinstance(where, str) else json.dumps(where)
if select:
params['select'] = select
if order_by:
params['orderBy'] = order_by
if order:
params['order'] = order
self._log(f"Listing related {entity_type}/{entity_id}/{link}")
return await self.api_call(f"/{entity_type}/{entity_id}/{link}", 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)
session = await self._get_session()
try:
async with session.post(url, headers=headers, data=form_data, timeout=effective_timeout) 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)
session = await self._get_session()
try:
async with session.get(url, headers=headers, timeout=effective_timeout) 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
# ========== Junction Table Operations ==========
async def get_junction_entries(
self,
junction_entity: str,
filter_field: str,
filter_value: str,
max_size: int = 1000
) -> List[Dict[str, Any]]:
"""
Load junction table entries with filtering.
Args:
junction_entity: Junction entity name (e.g., 'CAIKnowledgeCDokumente')
filter_field: Field to filter on (e.g., 'cAIKnowledgeId')
filter_value: Value to match
max_size: Maximum entries to return
Returns:
List of junction records with ALL additionalColumns
Example:
entries = await espocrm.get_junction_entries(
'CAIKnowledgeCDokumente',
'cAIKnowledgeId',
'kb-123'
)
"""
self._log(f"Loading junction entries: {junction_entity} where {filter_field}={filter_value}")
result = await self.list_entities(
junction_entity,
where=[{
'type': 'equals',
'attribute': filter_field,
'value': filter_value
}],
max_size=max_size
)
entries = result.get('list', [])
self._log(f"✅ Loaded {len(entries)} junction entries")
return entries
async def update_junction_entry(
self,
junction_entity: str,
junction_id: str,
fields: Dict[str, Any]
) -> None:
"""
Update junction table entry.
Args:
junction_entity: Junction entity name
junction_id: Junction entry ID
fields: Fields to update
Example:
await espocrm.update_junction_entry(
'CAIKnowledgeCDokumente',
'jct-123',
{'syncstatus': 'synced', 'lastSync': '2026-03-11T20:00:00Z'}
)
"""
await self.update_entity(junction_entity, junction_id, fields)
async def get_knowledge_documents_with_junction(
self,
knowledge_id: str
) -> List[Dict[str, Any]]:
"""
Get all documents linked to a CAIKnowledge entry with junction data.
Uses custom EspoCRM endpoint: GET /JunctionData/CAIKnowledge/{knowledge_id}/dokumentes
Returns enriched list with:
- junctionId: Junction table ID
- cAIKnowledgeId, cDokumenteId: Junction keys
- aiDocumentId: XAI document ID from junction
- syncstatus: Sync status from junction (new, synced, failed, unclean)
- lastSync: Last sync timestamp from junction
- documentId, documentName: Document info
- blake3hash: Blake3 hash from document entity
- documentCreatedAt, documentModifiedAt: Document timestamps
This consolidates multiple API calls into one efficient query.
Args:
knowledge_id: CAIKnowledge entity ID
Returns:
List of document dicts with junction data
Example:
docs = await espocrm.get_knowledge_documents_with_junction('69b1b03582bb6e2da')
for doc in docs:
print(f"{doc['documentName']}: {doc['syncstatus']}")
"""
# JunctionData uses API Gateway URL, not direct EspoCRM
# Use gateway URL from env or construct from ESPOCRM_API_BASE_URL
gateway_url = os.getenv('ESPOCRM_GATEWAY_URL', 'https://api.bitbylaw.com/vmh/crm')
url = f"{gateway_url}/JunctionData/CAIKnowledge/{knowledge_id}/dokumentes"
self._log(f"GET {url}")
try:
session = await self._get_session()
timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds)
async with session.get(url, headers=self._get_headers(), timeout=timeout) as response:
self._log(f"Response status: {response.status}")
if response.status == 404:
# Knowledge base not found or no documents linked
return []
if response.status >= 400:
error_text = await response.text()
raise EspoCRMAPIError(f"JunctionData GET failed: {response.status} - {error_text}")
result = await response.json()
documents = result.get('list', [])
self._log(f"✅ Loaded {len(documents)} document(s) with junction data")
return documents
except asyncio.TimeoutError:
raise EspoCRMTimeoutError(f"Timeout getting junction data for knowledge {knowledge_id}")
except aiohttp.ClientError as e:
raise EspoCRMAPIError(f"Network error getting junction data: {e}")
async def update_knowledge_document_junction(
self,
knowledge_id: str,
document_id: str,
fields: Dict[str, Any],
update_last_sync: bool = True
) -> Dict[str, Any]:
"""
Update junction columns for a specific document link.
Uses custom EspoCRM endpoint:
PUT /JunctionData/CAIKnowledge/{knowledge_id}/dokumentes/{document_id}
Args:
knowledge_id: CAIKnowledge entity ID
document_id: CDokumente entity ID
fields: Junction fields to update (aiDocumentId, syncstatus, etc.)
update_last_sync: Whether to update lastSync timestamp (default: True)
Returns:
Updated junction data
Example:
await espocrm.update_knowledge_document_junction(
'69b1b03582bb6e2da',
'69a68b556a39771bf',
{
'aiDocumentId': 'xai-file-abc123',
'syncstatus': 'synced'
},
update_last_sync=True
)
"""
# JunctionData uses API Gateway URL, not direct EspoCRM
gateway_url = os.getenv('ESPOCRM_GATEWAY_URL', 'https://api.bitbylaw.com/vmh/crm')
url = f"{gateway_url}/JunctionData/CAIKnowledge/{knowledge_id}/dokumentes/{document_id}"
payload = {**fields}
if update_last_sync:
payload['updateLastSync'] = True
self._log(f"PUT {url}")
self._log(f" Payload: {payload}")
try:
session = await self._get_session()
timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds)
async with session.put(url, headers=self._get_headers(), json=payload, timeout=timeout) as response:
self._log(f"Response status: {response.status}")
if response.status >= 400:
error_text = await response.text()
raise EspoCRMAPIError(f"JunctionData PUT failed: {response.status} - {error_text}")
result = await response.json()
self._log(f"✅ Junction updated: junctionId={result.get('junctionId')}")
return result
except asyncio.TimeoutError:
raise EspoCRMTimeoutError(f"Timeout updating junction data")
except aiohttp.ClientError as e:
raise EspoCRMAPIError(f"Network error updating junction data: {e}")

212
services/espocrm_mapper.py Normal file
View File

@@ -0,0 +1,212 @@
"""
EspoCRM ↔ Advoware Entity Mapper
Transformiert Beteiligte zwischen den beiden Systemen basierend auf ENTITY_MAPPING_CBeteiligte_Advoware.md
"""
from typing import Dict, Any, Optional, List
from datetime import datetime
import logging
from services.models import (
AdvowareBeteiligteCreate,
AdvowareBeteiligteUpdate,
EspoCRMBeteiligteCreate,
validate_beteiligte_advoware,
validate_beteiligte_espocrm
)
from services.exceptions import ValidationError
from services.config import FEATURE_FLAGS
class BeteiligteMapper:
"""Mapper für CBeteiligte (EspoCRM) ↔ Beteiligte (Advoware)"""
@staticmethod
def map_cbeteiligte_to_advoware(espo_entity: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert EspoCRM CBeteiligte → Advoware Beteiligte Format (STAMMDATEN)
WICHTIG: Kontaktdaten (Telefon, Email, Fax, Bankverbindungen) werden über
separate Advoware-Endpoints gesynct und sind NICHT Teil dieser Mapping-Funktion.
Args:
espo_entity: CBeteiligte Entity von EspoCRM
Returns:
Dict mit Stammdaten für Advoware API (POST/PUT /api/v1/advonet/Beteiligte)
Raises:
ValidationError: Bei Validierungsfehlern (wenn strict_validation aktiviert)
"""
logger.debug(f"Mapping EspoCRM → Advoware STAMMDATEN: {espo_entity.get('id')}")
# Bestimme ob Person oder Firma (über firmenname-Feld)
firmenname = espo_entity.get('firmenname')
is_firma = bool(firmenname and firmenname.strip())
# Basis-Struktur (nur die funktionierenden Felder!)
advo_data = {
'rechtsform': espo_entity.get('rechtsform', ''),
}
# NAME: Person vs. Firma
if is_firma:
# Firma: Lese von firmenname-Feld
advo_data['name'] = firmenname
advo_data['vorname'] = None
else:
# Natürliche Person: Lese von lastName/firstName
advo_data['name'] = espo_entity.get('lastName', '')
advo_data['vorname'] = espo_entity.get('firstName', '')
# ANREDE & TITEL (funktionierende Felder)
salutation = espo_entity.get('salutationName')
if salutation:
advo_data['anrede'] = salutation
titel = espo_entity.get('titel')
if titel:
advo_data['titel'] = titel
# BRIEFANREDE (bAnrede)
brief_anrede = espo_entity.get('briefAnrede')
if brief_anrede:
advo_data['bAnrede'] = brief_anrede
# ZUSATZ
zusatz = espo_entity.get('zusatz')
if zusatz:
advo_data['zusatz'] = zusatz
# GEBURTSDATUM
date_of_birth = espo_entity.get('dateOfBirth')
if date_of_birth:
advo_data['geburtsdatum'] = date_of_birth
# HINWEIS: handelsRegisterNummer und registergericht funktionieren NICHT!
# Advoware ignoriert diese Felder im PUT (trotz Swagger Schema)
logger.debug(f"Mapped to Advoware STAMMDATEN: name={advo_data.get('name')}, vorname={advo_data.get('vorname')}, rechtsform={advo_data.get('rechtsform')}")
# Optional: Validiere mit Pydantic wenn aktiviert
if FEATURE_FLAGS.strict_validation:
try:
validate_beteiligte_advoware(advo_data)
except ValidationError as e:
logger.warning(f"Validation warning: {e}")
# Continue anyway - validation ist optional
return advo_data
@staticmethod
def map_advoware_to_cbeteiligte(advo_entity: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert Advoware Beteiligte → EspoCRM CBeteiligte Format
Args:
advo_entity: Beteiligter von Advoware API
Returns:
Dict für EspoCRM API (POST/PUT /api/v1/CBeteiligte)
"""
logger.debug(f"Mapping Advoware → EspoCRM: betNr={advo_entity.get('betNr')}")
# Bestimme ob Person oder Firma
vorname = advo_entity.get('vorname')
is_person = bool(vorname)
# Basis-Struktur
espo_data = {
'rechtsform': advo_entity.get('rechtsform', ''),
'betnr': advo_entity.get('betNr'), # Link zu Advoware
'advowareRowId': advo_entity.get('rowId'), # Änderungserkennung
}
# NAME: Person vs. Firma (EspoCRM blendet lastName/firstName aus bei Firmen)
if is_person:
# Natürliche Person → lastName/firstName verwenden
espo_data['firstName'] = vorname
espo_data['lastName'] = advo_entity.get('name', '')
espo_data['name'] = f"{vorname} {advo_entity.get('name', '')}".strip()
espo_data['firmenname'] = None # Firma-Feld leer lassen
else:
# Firma → firmenname verwenden (EspoCRM zeigt dann nur dieses Feld)
firma_name = advo_entity.get('name', '')
espo_data['firmenname'] = firma_name
espo_data['name'] = firma_name
# lastName/firstName nicht setzen (EspoCRM blendet sie aus bei Firmen)
espo_data['firstName'] = None
espo_data['lastName'] = None
# ANREDE & TITEL
anrede = advo_entity.get('anrede')
if anrede:
espo_data['salutationName'] = anrede
titel = advo_entity.get('titel')
if titel:
espo_data['titel'] = titel
# BRIEFANREDE
b_anrede = advo_entity.get('bAnrede')
if b_anrede:
espo_data['briefAnrede'] = b_anrede
# ZUSATZ
zusatz = advo_entity.get('zusatz')
if zusatz:
espo_data['zusatz'] = zusatz
# GEBURTSDATUM (nur Datum-Teil ohne Zeit)
geburtsdatum = advo_entity.get('geburtsdatum')
if geburtsdatum:
# Advoware gibt '2001-01-05T00:00:00', EspoCRM will nur '2001-01-05'
espo_data['dateOfBirth'] = geburtsdatum.split('T')[0] if 'T' in geburtsdatum else geburtsdatum
logger.debug(f"Mapped to EspoCRM STAMMDATEN: name={espo_data.get('name')}")
# WICHTIG: Entferne None-Werte (EspoCRM mag keine expliziten None bei required fields)
espo_data = {k: v for k, v in espo_data.items() if v is not None}
return espo_data
@staticmethod
def get_changed_fields(espo_entity: Dict[str, Any], advo_entity: Dict[str, Any]) -> List[str]:
"""
Vergleicht zwei Entities und gibt Liste der geänderten Felder zurück
Args:
espo_entity: EspoCRM CBeteiligte
advo_entity: Advoware Beteiligte
Returns:
Liste von Feldnamen die unterschiedlich sind
"""
# Mappe Advoware zu EspoCRM Format für Vergleich
mapped_advo = BeteiligteMapper.map_advoware_to_cbeteiligte(advo_entity)
changed = []
# Vergleiche wichtige Felder
compare_fields = [
'name', 'firstName', 'lastName', 'firmenname',
'emailAddress', 'phoneNumber',
'dateOfBirth', 'rechtsform',
'handelsregisterNummer', 'handelsregisterArt', 'registergericht',
'betnr', 'advowareRowId'
]
for field in compare_fields:
espo_val = espo_entity.get(field)
advo_val = mapped_advo.get(field)
# Normalisiere None und leere Strings
espo_val = espo_val if espo_val else None
advo_val = advo_val if advo_val else None
if espo_val != advo_val:
changed.append(field)
logger.debug(f"Field '{field}' changed: EspoCRM='{espo_val}' vs Advoware='{advo_val}'")
return changed

217
services/exceptions.py Normal file
View File

@@ -0,0 +1,217 @@
"""
Custom Exception Classes für BitByLaw Integration
Hierarchie:
- IntegrationError (Base)
- APIError
- AdvowareAPIError
- AdvowareAuthError
- AdvowareTimeoutError
- EspoCRMAPIError
- EspoCRMAuthError
- EspoCRMTimeoutError
- SyncError
- LockAcquisitionError
- ValidationError
- ConflictError
- RetryableError
- NonRetryableError
"""
from typing import Optional, Dict, Any
class IntegrationError(Exception):
"""Base exception for all integration errors"""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
super().__init__(message)
self.message = message
self.details = details or {}
# ========== API Errors ==========
class APIError(IntegrationError):
"""Base class for all API-related errors"""
def __init__(
self,
message: str,
status_code: Optional[int] = None,
response_body: Optional[str] = None,
details: Optional[Dict[str, Any]] = None
):
super().__init__(message, details)
self.status_code = status_code
self.response_body = response_body
class AdvowareAPIError(APIError):
"""Advoware API error"""
pass
class AdvowareAuthError(AdvowareAPIError):
"""Advoware authentication error"""
pass
class AdvowareTimeoutError(AdvowareAPIError):
"""Advoware API timeout"""
pass
class EspoCRMAPIError(APIError):
"""EspoCRM API error"""
pass
class EspoCRMAuthError(EspoCRMAPIError):
"""EspoCRM authentication error"""
pass
class EspoCRMTimeoutError(EspoCRMAPIError):
"""EspoCRM API timeout"""
pass
# ========== Sync Errors ==========
class SyncError(IntegrationError):
"""Base class for synchronization errors"""
pass
class LockAcquisitionError(SyncError):
"""Failed to acquire distributed lock"""
def __init__(self, entity_id: str, lock_key: str, message: Optional[str] = None):
super().__init__(
message or f"Could not acquire lock for entity {entity_id}",
details={"entity_id": entity_id, "lock_key": lock_key}
)
self.entity_id = entity_id
self.lock_key = lock_key
class ValidationError(SyncError):
"""Data validation error"""
def __init__(self, message: str, field: Optional[str] = None, value: Any = None):
super().__init__(
message,
details={"field": field, "value": value}
)
self.field = field
self.value = value
class ConflictError(SyncError):
"""Data conflict during synchronization"""
def __init__(
self,
message: str,
entity_id: str,
source_system: Optional[str] = None,
target_system: Optional[str] = None
):
super().__init__(
message,
details={
"entity_id": entity_id,
"source_system": source_system,
"target_system": target_system
}
)
self.entity_id = entity_id
# ========== Retry Classification ==========
class RetryableError(IntegrationError):
"""Error that should trigger retry logic"""
def __init__(
self,
message: str,
retry_after_seconds: Optional[int] = None,
details: Optional[Dict[str, Any]] = None
):
super().__init__(message, details)
self.retry_after_seconds = retry_after_seconds
class NonRetryableError(IntegrationError):
"""Error that should NOT trigger retry (e.g., validation errors)"""
pass
# ========== Redis Errors ==========
class RedisError(IntegrationError):
"""Redis connection or operation error"""
def __init__(self, message: str, operation: Optional[str] = None):
super().__init__(message, details={"operation": operation})
self.operation = operation
class RedisConnectionError(RedisError):
"""Redis connection failed"""
pass
# ========== Helper Functions ==========
def is_retryable(error: Exception) -> bool:
"""
Determine if an error should trigger retry logic.
Args:
error: Exception to check
Returns:
True if error is retryable
"""
if isinstance(error, NonRetryableError):
return False
if isinstance(error, RetryableError):
return True
if isinstance(error, (AdvowareTimeoutError, EspoCRMTimeoutError)):
return True
if isinstance(error, ValidationError):
return False
# Default: assume retryable for API errors
if isinstance(error, APIError):
return True
return False
def get_retry_delay(error: Exception, attempt: int) -> int:
"""
Calculate retry delay based on error type and attempt number.
Args:
error: The error that occurred
attempt: Current retry attempt (0-indexed)
Returns:
Delay in seconds
"""
if isinstance(error, RetryableError) and error.retry_after_seconds:
return error.retry_after_seconds
# Exponential backoff: [1, 5, 15, 60, 240] minutes
backoff_minutes = [1, 5, 15, 60, 240]
if attempt < len(backoff_minutes):
return backoff_minutes[attempt] * 60
return backoff_minutes[-1] * 60

View File

@@ -0,0 +1,333 @@
"""
Kommunikation Mapper: Advoware ↔ EspoCRM
Mapping-Strategie:
- Marker in Advoware bemerkung: [ESPOCRM:hash:kommKz]
- Typ-Erkennung: Marker > Top-Level > Wert > Default
- Bidirektional mit Slot-Wiederverwendung
"""
import hashlib
import base64
import re
from typing import Optional, Dict, Any, List, Tuple
# kommKz Enum
KOMMKZ_TEL_GESCH = 1
KOMMKZ_FAX_GESCH = 2
KOMMKZ_MOBIL = 3
KOMMKZ_MAIL_GESCH = 4
KOMMKZ_INTERNET = 5
KOMMKZ_TEL_PRIVAT = 6
KOMMKZ_FAX_PRIVAT = 7
KOMMKZ_MAIL_PRIVAT = 8
KOMMKZ_AUTO_TELEFON = 9
KOMMKZ_SONSTIGE = 10
KOMMKZ_EPOST = 11
KOMMKZ_BEA = 12
# EspoCRM phone type mapping
KOMMKZ_TO_PHONE_TYPE = {
KOMMKZ_TEL_GESCH: 'Office',
KOMMKZ_FAX_GESCH: 'Fax',
KOMMKZ_MOBIL: 'Mobile',
KOMMKZ_TEL_PRIVAT: 'Home',
KOMMKZ_FAX_PRIVAT: 'Fax',
KOMMKZ_AUTO_TELEFON: 'Mobile',
KOMMKZ_SONSTIGE: 'Other',
}
# Reverse mapping: EspoCRM phone type to kommKz
PHONE_TYPE_TO_KOMMKZ = {
'Office': KOMMKZ_TEL_GESCH,
'Fax': KOMMKZ_FAX_GESCH,
'Mobile': KOMMKZ_MOBIL,
'Home': KOMMKZ_TEL_PRIVAT,
'Other': KOMMKZ_SONSTIGE,
}
# Email kommKz values
EMAIL_KOMMKZ = [KOMMKZ_MAIL_GESCH, KOMMKZ_MAIL_PRIVAT, KOMMKZ_EPOST, KOMMKZ_BEA]
# Phone kommKz values
PHONE_KOMMKZ = [KOMMKZ_TEL_GESCH, KOMMKZ_FAX_GESCH, KOMMKZ_MOBIL,
KOMMKZ_TEL_PRIVAT, KOMMKZ_FAX_PRIVAT, KOMMKZ_AUTO_TELEFON, KOMMKZ_SONSTIGE]
def encode_value(value: str) -> str:
"""Encodiert Wert mit Base64 (URL-safe) für Marker"""
return base64.urlsafe_b64encode(value.encode('utf-8')).decode('ascii').rstrip('=')
def decode_value(encoded: str) -> str:
"""Decodiert Base64-kodierten Wert aus Marker"""
# Add padding if needed
padding = 4 - (len(encoded) % 4)
if padding != 4:
encoded += '=' * padding
return base64.urlsafe_b64decode(encoded.encode('ascii')).decode('utf-8')
def calculate_hash(value: str) -> str:
"""Legacy: Hash-Berechnung (für Rückwärtskompatibilität mit alten Markern)"""
return hashlib.sha256(value.encode()).hexdigest()[:8]
def parse_marker(bemerkung: str) -> Optional[Dict[str, Any]]:
"""
Parse ESPOCRM-Marker aus bemerkung
Returns:
{'synced_value': '...', 'kommKz': 4, 'is_slot': False, 'user_text': '...'}
oder None (synced_value ist decoded, nicht base64)
"""
if not bemerkung:
return None
# Match SLOT: [ESPOCRM-SLOT:kommKz]
slot_pattern = r'\[ESPOCRM-SLOT:(\d+)\](.*)'
slot_match = re.match(slot_pattern, bemerkung)
if slot_match:
return {
'synced_value': '',
'kommKz': int(slot_match.group(1)),
'is_slot': True,
'user_text': slot_match.group(2).strip()
}
# Match: [ESPOCRM:base64_value:kommKz]
pattern = r'\[ESPOCRM:([^:]+):(\d+)\](.*)'
match = re.match(pattern, bemerkung)
if not match:
return None
encoded_value = match.group(1)
# Decode Base64 value
try:
synced_value = decode_value(encoded_value)
except Exception as e:
# Fallback: Könnte alter Hash-Marker sein
synced_value = encoded_value
return {
'synced_value': synced_value,
'kommKz': int(match.group(2)),
'is_slot': False,
'user_text': match.group(3).strip()
}
def create_marker(value: str, kommkz: int, user_text: str = '') -> str:
"""Erstellt ESPOCRM-Marker mit Base64-encodiertem Wert"""
encoded = encode_value(value)
suffix = f" {user_text}" if user_text else ""
return f"[ESPOCRM:{encoded}:{kommkz}]{suffix}"
def create_slot_marker(kommkz: int) -> str:
"""Erstellt Slot-Marker für gelöschte Einträge"""
return f"[ESPOCRM-SLOT:{kommkz}]"
def detect_kommkz(value: str, beteiligte: Optional[Dict] = None,
bemerkung: Optional[str] = None,
espo_type: Optional[str] = None) -> int:
"""
Erkenne kommKz mit mehrstufiger Strategie
Priorität:
1. Aus bemerkung-Marker (wenn vorhanden)
2. Aus EspoCRM type (wenn von EspoCRM kommend)
3. Aus Top-Level Feldern in beteiligte
4. Aus Wert (Email vs. Phone)
5. Default
Args:
espo_type: EspoCRM phone type ('Office', 'Mobile', 'Fax', etc.) oder 'email'
"""
# 1. Aus Marker
if bemerkung:
marker = parse_marker(bemerkung)
if marker:
import logging
logger = logging.getLogger(__name__)
logger.info(f"[KOMMKZ] Detected from marker: kommKz={marker['kommKz']}")
return marker['kommKz']
# 2. Aus EspoCRM type (für EspoCRM->Advoware Sync)
if espo_type:
if espo_type == 'email':
import logging
logger = logging.getLogger(__name__)
logger.info(f"[KOMMKZ] Detected from espo_type 'email': kommKz={KOMMKZ_MAIL_GESCH}")
return KOMMKZ_MAIL_GESCH
elif espo_type in PHONE_TYPE_TO_KOMMKZ:
kommkz = PHONE_TYPE_TO_KOMMKZ[espo_type]
import logging
logger = logging.getLogger(__name__)
logger.info(f"[KOMMKZ] Detected from espo_type '{espo_type}': kommKz={kommkz}")
return kommkz
# 3. Aus Top-Level Feldern (für genau EINEN Eintrag pro Typ)
if beteiligte:
top_level_map = {
'telGesch': KOMMKZ_TEL_GESCH,
'faxGesch': KOMMKZ_FAX_GESCH,
'mobil': KOMMKZ_MOBIL,
'emailGesch': KOMMKZ_MAIL_GESCH,
'email': KOMMKZ_MAIL_GESCH,
'internet': KOMMKZ_INTERNET,
'telPrivat': KOMMKZ_TEL_PRIVAT,
'faxPrivat': KOMMKZ_FAX_PRIVAT,
'autotelefon': KOMMKZ_AUTO_TELEFON,
'ePost': KOMMKZ_EPOST,
'bea': KOMMKZ_BEA,
}
for field, kommkz in top_level_map.items():
if beteiligte.get(field) == value:
return kommkz
# 3. Aus Wert (Email vs. Phone)
if '@' in value:
return KOMMKZ_MAIL_GESCH # Default Email
elif value.strip():
return KOMMKZ_TEL_GESCH # Default Phone
return 0
def is_email_type(kommkz: int) -> bool:
"""Prüft ob kommKz ein Email-Typ ist"""
return kommkz in EMAIL_KOMMKZ
def is_phone_type(kommkz: int) -> bool:
"""Prüft ob kommKz ein Telefon-Typ ist"""
return kommkz in PHONE_KOMMKZ
def advoware_to_espocrm_email(advo_komm: Dict, beteiligte: Dict) -> Dict[str, Any]:
"""
Konvertiert Advoware Kommunikation zu EspoCRM emailAddressData
Args:
advo_komm: Advoware Kommunikation
beteiligte: Vollständiger Beteiligte (für Top-Level Felder)
Returns:
EspoCRM emailAddressData Element
"""
value = (advo_komm.get('tlf') or '').strip()
return {
'emailAddress': value,
'lower': value.lower(),
'primary': advo_komm.get('online', False),
'optOut': False,
'invalid': False
}
def advoware_to_espocrm_phone(advo_komm: Dict, beteiligte: Dict) -> Dict[str, Any]:
"""
Konvertiert Advoware Kommunikation zu EspoCRM phoneNumberData
Args:
advo_komm: Advoware Kommunikation
beteiligte: Vollständiger Beteiligte (für Top-Level Felder)
Returns:
EspoCRM phoneNumberData Element
"""
value = (advo_komm.get('tlf') or '').strip()
bemerkung = advo_komm.get('bemerkung')
# Erkenne kommKz
kommkz = detect_kommkz(value, beteiligte, bemerkung)
# Mappe zu EspoCRM type
phone_type = KOMMKZ_TO_PHONE_TYPE.get(kommkz, 'Other')
return {
'phoneNumber': value,
'type': phone_type,
'primary': advo_komm.get('online', False),
'optOut': False,
'invalid': False
}
def find_matching_advoware(espo_value: str, advo_kommunikationen: List[Dict]) -> Optional[Dict]:
"""
Findet passende Advoware-Kommunikation für EspoCRM Wert
Matching via synced_value in bemerkung-Marker
"""
for k in advo_kommunikationen:
bemerkung = k.get('bemerkung') or ''
marker = parse_marker(bemerkung)
if marker and not marker['is_slot'] and marker['synced_value'] == espo_value:
return k
return None
def find_empty_slot(kommkz: int, advo_kommunikationen: List[Dict]) -> Optional[Dict]:
"""
Findet leeren Slot mit passendem kommKz
Leere Slots haben: tlf='' (WIRKLICH leer!) UND bemerkung='[ESPOCRM-SLOT:kommKz]'
WICHTIG: User könnte Wert in einen Slot eingetragen haben → dann ist es KEIN Empty Slot mehr!
"""
for k in advo_kommunikationen:
tlf = (k.get('tlf') or '').strip()
bemerkung = k.get('bemerkung') or ''
# Muss BEIDES erfüllen: tlf leer UND Slot-Marker
if not tlf:
marker = parse_marker(bemerkung)
if marker and marker.get('is_slot') and marker.get('kommKz') == kommkz:
return k
return None
def should_sync_to_espocrm(advo_komm: Dict) -> bool:
"""
Prüft ob Advoware-Kommunikation zu EspoCRM synchronisiert werden soll
Nur wenn:
- Wert vorhanden (tlf ist nicht leer)
WICHTIG: Ein Slot-Marker allein bedeutet NICHT "nicht sync-relevant"!
User könnte einen Wert in einen Slot eingetragen haben.
"""
tlf = (advo_komm.get('tlf') or '').strip()
# Nur relevante Kriterium: Hat tlf einen Wert?
return bool(tlf)
def get_user_bemerkung(advo_komm: Dict) -> str:
"""Extrahiert User-Bemerkung (ohne Marker)"""
bemerkung = advo_komm.get('bemerkung') or ''
marker = parse_marker(bemerkung)
if marker:
return marker['user_text']
return bemerkung
def set_user_bemerkung(marker: str, user_text: str) -> str:
"""Fügt User-Bemerkung zu Marker hinzu"""
if user_text:
return f"{marker} {user_text}"
return marker

View File

@@ -0,0 +1,996 @@
"""
Kommunikation Sync Utilities
Bidirektionale Synchronisation: Advoware ↔ EspoCRM
Strategie:
- Emails: emailAddressData[] ↔ Advoware Kommunikationen (kommKz: 4,8,11,12)
- Phones: phoneNumberData[] ↔ Advoware Kommunikationen (kommKz: 1,2,3,6,7,9,10)
- Matching: Hash-basiert via bemerkung-Marker
- Type Detection: Marker > Top-Level > Value Pattern > Default
"""
import logging
from typing import Dict, List, Optional, Tuple, Any
from services.kommunikation_mapper import (
parse_marker, create_marker, create_slot_marker,
detect_kommkz, encode_value, decode_value,
is_email_type, is_phone_type,
advoware_to_espocrm_email, advoware_to_espocrm_phone,
find_matching_advoware, find_empty_slot,
should_sync_to_espocrm, get_user_bemerkung,
calculate_hash,
EMAIL_KOMMKZ, PHONE_KOMMKZ
)
from services.advoware_service import AdvowareService
from services.espocrm import EspoCRMAPI
class KommunikationSyncManager:
"""Manager für Kommunikation-Synchronisation"""
def __init__(self, advoware: AdvowareService, espocrm: EspoCRMAPI, context=None):
self.advoware = advoware
self.espocrm = espocrm
self.context = context
self.logger = context.logger if context else logger
# ========== BIDIRECTIONAL SYNC ==========
async def sync_bidirectional(self, beteiligte_id: str, betnr: int,
direction: str = 'both', force_espo_wins: bool = False) -> Dict[str, Any]:
"""
Bidirektionale Synchronisation mit intelligentem Diffing
Optimiert:
- Lädt Daten nur 1x von jeder Seite (kein doppelter API-Call)
- Echtes 3-Way Diffing (Advoware, EspoCRM, Marker)
- Handhabt alle 6 Szenarien korrekt (Var1-6)
- Initial Sync: Value-Matching verhindert Duplikate (BUG-3 Fix)
- Hash nur bei Änderung schreiben (Performance)
- Lock-Release garantiert via try/finally
Args:
direction: 'both', 'to_espocrm', 'to_advoware'
force_espo_wins: Erzwingt EspoCRM-wins Konfliktlösung (für Stammdaten-Konflikte)
Returns:
Combined results mit detaillierten Änderungen
"""
result = {
'advoware_to_espocrm': {'emails_synced': 0, 'phones_synced': 0, 'errors': []},
'espocrm_to_advoware': {'created': 0, 'updated': 0, 'deleted': 0, 'errors': []},
'summary': {'total_changes': 0}
}
# NOTE: Lock-Management erfolgt außerhalb dieser Methode (in Event/Cron Handler)
# Diese Methode ist für die reine Sync-Logik zuständig
try:
# ========== LADE DATEN NUR 1X ==========
self.logger.info(f"[KOMM] Bidirectional Sync: betnr={betnr}, bet_id={beteiligte_id}")
# Advoware Daten
advo_result = await self.advoware.get_beteiligter(betnr)
if isinstance(advo_result, list):
advo_bet = advo_result[0] if advo_result else None
else:
advo_bet = advo_result
if not advo_bet:
result['advoware_to_espocrm']['errors'].append("Advoware Beteiligte nicht gefunden")
result['espocrm_to_advoware']['errors'].append("Advoware Beteiligte nicht gefunden")
return result
# EspoCRM Daten
espo_bet = await self.espocrm.get_entity('CBeteiligte', beteiligte_id)
if not espo_bet:
result['advoware_to_espocrm']['errors'].append("EspoCRM Beteiligte nicht gefunden")
result['espocrm_to_advoware']['errors'].append("EspoCRM Beteiligte nicht gefunden")
return result
advo_kommunikationen = advo_bet.get('kommunikation', [])
espo_emails = espo_bet.get('emailAddressData', [])
espo_phones = espo_bet.get('phoneNumberData', [])
self.logger.info(f"[KOMM] Geladen: {len(advo_kommunikationen)} Advoware, {len(espo_emails)} EspoCRM emails, {len(espo_phones)} EspoCRM phones")
# Check ob initialer Sync
stored_komm_hash = espo_bet.get('kommunikationHash')
is_initial_sync = not stored_komm_hash
# ========== 3-WAY DIFFING MIT HASH-BASIERTER KONFLIKT-ERKENNUNG ==========
diff = self._compute_diff(advo_kommunikationen, espo_emails, espo_phones, advo_bet, espo_bet)
# WICHTIG: force_espo_wins überschreibt den Hash-basierten Konflikt-Check
if force_espo_wins:
diff['espo_wins'] = True
self.logger.info(f"[KOMM] ⚠️ force_espo_wins=True → EspoCRM WINS (override)")
# Konvertiere Var3 (advo_deleted) → Var1 (espo_new)
# Bei Konflikt müssen gelöschte Advoware-Einträge wiederhergestellt werden
if diff['advo_deleted']:
self.logger.info(f"[KOMM] 🔄 Converting {len(diff['advo_deleted'])} Var3→Var1 (force EspoCRM wins)")
for value, espo_item in diff['advo_deleted']:
diff['espo_new'].append((value, espo_item))
diff['advo_deleted'] = [] # Leeren, da jetzt als Var1 behandelt
espo_wins = diff.get('espo_wins', False)
self.logger.info(f"[KOMM] ===== DIFF RESULTS =====")
self.logger.info(f"[KOMM] Diff: {len(diff['advo_changed'])} Advoware changed, {len(diff['espo_changed'])} EspoCRM changed, "
f"{len(diff['advo_new'])} Advoware new, {len(diff['espo_new'])} EspoCRM new, "
f"{len(diff['advo_deleted'])} Advoware deleted, {len(diff['espo_deleted'])} EspoCRM deleted")
force_status = " (force=True)" if force_espo_wins else ""
self.logger.info(f"[KOMM] ===== CONFLICT STATUS: espo_wins={espo_wins}{force_status} =====")
# ========== APPLY CHANGES ==========
# Bestimme Sync-Richtungen und Konflikt-Handling
sync_to_espocrm = direction in ['both', 'to_espocrm']
sync_to_advoware = direction in ['both', 'to_advoware']
should_revert_advoware_changes = (sync_to_espocrm and espo_wins) or (direction == 'to_advoware')
# 1. Advoware → EspoCRM (Var4: Neu in Advoware, Var6: Geändert in Advoware)
if sync_to_espocrm and not espo_wins:
self.logger.info(f"[KOMM] ✅ Applying Advoware→EspoCRM changes...")
espo_result = await self._apply_advoware_to_espocrm(
beteiligte_id, diff, advo_bet
)
result['advoware_to_espocrm'] = espo_result
# Bei Konflikt oder direction='to_advoware': Revert Advoware-Änderungen
if should_revert_advoware_changes:
if espo_wins:
self.logger.info(f"[KOMM] ⚠️ CONFLICT: EspoCRM wins - reverting Advoware changes")
else:
self.logger.info(f"[KOMM] Direction={direction}: reverting Advoware changes")
# Var6: Revert Änderungen
if len(diff['advo_changed']) > 0:
self.logger.info(f"[KOMM] 🔄 Reverting {len(diff['advo_changed'])} Var6 entries to EspoCRM values...")
for komm, old_value, new_value in diff['advo_changed']:
await self._revert_advoware_change(betnr, komm, old_value, new_value, advo_bet)
result['espocrm_to_advoware']['updated'] += 1
# Var4: Convert to Empty Slots
if len(diff['advo_new']) > 0:
self.logger.info(f"[KOMM] 🔄 Converting {len(diff['advo_new'])} Var4 entries to Empty Slots...")
for komm in diff['advo_new']:
await self._create_empty_slot(betnr, komm)
result['espocrm_to_advoware']['deleted'] += 1
# Var3: Wiederherstellung gelöschter Einträge (kein separater Code nötig)
# → Wird über Var1 in _apply_espocrm_to_advoware behandelt
# Die gelöschten Einträge sind noch in EspoCRM vorhanden und werden als "espo_new" erkannt
if len(diff['advo_deleted']) > 0:
self.logger.info(f"[KOMM] {len(diff['advo_deleted'])} Var3 entries (deleted in Advoware) will be restored via espo_new")
# 2. EspoCRM → Advoware (Var1: Neu in EspoCRM, Var2: Gelöscht in EspoCRM, Var5: Geändert in EspoCRM)
if sync_to_advoware:
advo_result = await self._apply_espocrm_to_advoware(
betnr, diff, advo_bet
)
# Merge results (Var6/Var4 Counts aus Konflikt-Handling behalten)
result['espocrm_to_advoware']['created'] += advo_result['created']
result['espocrm_to_advoware']['updated'] += advo_result['updated']
result['espocrm_to_advoware']['deleted'] += advo_result['deleted']
result['espocrm_to_advoware']['errors'].extend(advo_result['errors'])
# 3. Initial Sync Matches: Nur Marker setzen (keine CREATE/UPDATE)
if is_initial_sync and 'initial_sync_matches' in diff:
self.logger.info(f"[KOMM] ✓ Processing {len(diff['initial_sync_matches'])} initial sync matches...")
for value, matched_komm, espo_item in diff['initial_sync_matches']:
# Erkenne kommKz
espo_type = espo_item.get('type', 'email' if '@' in value else None)
kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type)
# Setze Marker in Advoware
await self.advoware.update_kommunikation(betnr, matched_komm['id'], {
'bemerkung': create_marker(value, kommkz),
'online': espo_item.get('primary', False)
})
result['espocrm_to_advoware']['updated'] += 1
total_changes = (
result['advoware_to_espocrm']['emails_synced'] +
result['advoware_to_espocrm']['phones_synced'] +
result['espocrm_to_advoware']['created'] +
result['espocrm_to_advoware']['updated'] +
result['espocrm_to_advoware']['deleted']
)
result['summary']['total_changes'] = total_changes
# Hash-Update: Immer berechnen, aber nur schreiben wenn geändert
import hashlib
# FIX: Nur neu laden wenn Änderungen gemacht wurden
if total_changes > 0:
advo_result_final = await self.advoware.get_beteiligter(betnr)
if isinstance(advo_result_final, list):
advo_bet_final = advo_result_final[0]
else:
advo_bet_final = advo_result_final
final_kommunikationen = advo_bet_final.get('kommunikation', [])
else:
# Keine Änderungen: Verwende cached data (keine doppelte API-Call)
final_kommunikationen = advo_bet.get('kommunikation', [])
# Berechne neuen Hash
sync_relevant_komm = [
k for k in final_kommunikationen
if should_sync_to_espocrm(k)
]
komm_rowids = sorted([k.get('rowId', '') for k in sync_relevant_komm if k.get('rowId')])
new_komm_hash = hashlib.md5(''.join(komm_rowids).encode()).hexdigest()[:16]
# Nur schreiben wenn Hash sich geändert hat oder Initial Sync
if new_komm_hash != stored_komm_hash:
await self.espocrm.update_entity('CBeteiligte', beteiligte_id, {
'kommunikationHash': new_komm_hash
})
self.logger.info(f"[KOMM] ✅ Updated kommunikationHash: {stored_komm_hash}{new_komm_hash} (based on {len(sync_relevant_komm)} sync-relevant of {len(final_kommunikationen)} total)")
else:
self.logger.info(f"[KOMM] Hash unchanged: {new_komm_hash} - no EspoCRM update needed")
self.logger.info(f"[KOMM] ✅ Bidirectional Sync complete: {total_changes} total changes")
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler bei Bidirectional Sync: {e}")
self.logger.error(traceback.format_exc())
result['advoware_to_espocrm']['errors'].append(str(e))
result['espocrm_to_advoware']['errors'].append(str(e))
return result
# ========== 3-WAY DIFFING ==========
def _compute_diff(self, advo_kommunikationen: List[Dict], espo_emails: List[Dict],
espo_phones: List[Dict], advo_bet: Dict, espo_bet: Dict) -> Dict[str, List]:
"""
Berechnet Diff zwischen Advoware und EspoCRM mit Hash-basierter Konflikt-Erkennung
Returns:
Dict mit Var1-6 Änderungen und Konflikt-Status
"""
diff = {
'advo_changed': [], # Var6
'advo_new': [], # Var4
'advo_deleted': [], # Var3
'espo_changed': [], # Var5
'espo_new': [], # Var1
'espo_deleted': [], # Var2
'no_change': [],
'espo_wins': False
}
# 1. Konflikt-Erkennung
is_initial_sync, espo_wins, espo_changed_since_sync, advo_changed_since_sync = \
self._detect_conflict(advo_kommunikationen, espo_bet)
diff['espo_wins'] = espo_wins
# 2. Baue Value-Maps
espo_values = self._build_espocrm_value_map(espo_emails, espo_phones)
advo_with_marker, advo_without_marker = self._build_advoware_maps(advo_kommunikationen)
# 3. Analysiere Advoware-Einträge MIT Marker
self._analyze_advoware_with_marker(advo_with_marker, espo_values, diff)
# 4. Analysiere Advoware-Einträge OHNE Marker (Var4) + Initial Sync Matching
self._analyze_advoware_without_marker(
advo_without_marker, espo_values, is_initial_sync, advo_bet, diff
)
# 5. Analysiere EspoCRM-Einträge die nicht in Advoware sind (Var1/Var3)
self._analyze_espocrm_only(
espo_values, advo_with_marker, espo_wins,
espo_changed_since_sync, advo_changed_since_sync, diff
)
return diff
def _detect_conflict(self, advo_kommunikationen: List[Dict], espo_bet: Dict) -> Tuple[bool, bool, bool, bool]:
"""
Erkennt Konflikte via Hash-Vergleich
Returns:
(is_initial_sync, espo_wins, espo_changed_since_sync, advo_changed_since_sync)
"""
espo_modified = espo_bet.get('modifiedAt')
last_sync = espo_bet.get('advowareLastSync')
stored_komm_hash = espo_bet.get('kommunikationHash')
# Berechne aktuellen Hash
import hashlib
sync_relevant_komm = [k for k in advo_kommunikationen if should_sync_to_espocrm(k)]
komm_rowids = sorted([k.get('rowId', '') for k in sync_relevant_komm if k.get('rowId')])
current_advo_hash = hashlib.md5(''.join(komm_rowids).encode()).hexdigest()[:16]
# Parse Timestamps
from services.beteiligte_sync_utils import BeteiligteSync
espo_modified_ts = BeteiligteSync.parse_timestamp(espo_modified)
last_sync_ts = BeteiligteSync.parse_timestamp(last_sync)
# Bestimme Änderungen
espo_changed_since_sync = espo_modified_ts and last_sync_ts and espo_modified_ts > last_sync_ts
advo_changed_since_sync = stored_komm_hash and current_advo_hash != stored_komm_hash
is_initial_sync = not stored_komm_hash
# Konflikt-Logik: Beide geändert → EspoCRM wins
espo_wins = espo_changed_since_sync and advo_changed_since_sync
self.logger.info(f"[KOMM] 🔍 Konflikt-Check:")
self.logger.info(f"[KOMM] - EspoCRM changed: {espo_changed_since_sync}, Advoware changed: {advo_changed_since_sync}")
self.logger.info(f"[KOMM] - Initial sync: {is_initial_sync}, Conflict: {espo_wins}")
self.logger.info(f"[KOMM] - Hash: stored={stored_komm_hash}, current={current_advo_hash}")
return is_initial_sync, espo_wins, espo_changed_since_sync, advo_changed_since_sync
def _build_espocrm_value_map(self, espo_emails: List[Dict], espo_phones: List[Dict]) -> Dict[str, Dict]:
"""Baut Map: value → {value, is_email, primary, type}"""
espo_values = {}
for email in espo_emails:
val = email.get('emailAddress', '').strip()
if val:
espo_values[val] = {
'value': val,
'is_email': True,
'primary': email.get('primary', False),
'type': 'email'
}
for phone in espo_phones:
val = phone.get('phoneNumber', '').strip()
if val:
espo_values[val] = {
'value': val,
'is_email': False,
'primary': phone.get('primary', False),
'type': phone.get('type', 'Office')
}
return espo_values
def _build_advoware_maps(self, advo_kommunikationen: List[Dict]) -> Tuple[Dict, List]:
"""
Trennt Advoware-Einträge in MIT Marker und OHNE Marker
Returns:
(advo_with_marker: {synced_value: (komm, current_value)}, advo_without_marker: [komm])
"""
advo_with_marker = {}
advo_without_marker = []
for komm in advo_kommunikationen:
if not should_sync_to_espocrm(komm):
continue
tlf = (komm.get('tlf') or '').strip()
if not tlf:
continue
marker = parse_marker(komm.get('bemerkung', ''))
if marker and not marker['is_slot']:
# Hat Marker → Von EspoCRM synchronisiert
advo_with_marker[marker['synced_value']] = (komm, tlf)
else:
# Kein Marker → Von Advoware angelegt (Var4)
advo_without_marker.append(komm)
return advo_with_marker, advo_without_marker
def _analyze_advoware_with_marker(self, advo_with_marker: Dict, espo_values: Dict, diff: Dict) -> None:
"""Analysiert Advoware-Einträge MIT Marker für Var6, Var5, Var2"""
for synced_value, (komm, current_value) in advo_with_marker.items():
if synced_value != current_value:
# Var6: In Advoware geändert
self.logger.info(f"[KOMM] ✏️ Var6: Changed in Advoware")
diff['advo_changed'].append((komm, synced_value, current_value))
elif synced_value in espo_values:
espo_item = espo_values[synced_value]
current_online = komm.get('online', False)
espo_primary = espo_item['primary']
if current_online != espo_primary:
# Var5: EspoCRM hat primary geändert
self.logger.info(f"[KOMM] 🔄 Var5: Primary changed in EspoCRM")
diff['espo_changed'].append((synced_value, komm, espo_item))
else:
# Keine Änderung
diff['no_change'].append((synced_value, komm, espo_item))
else:
# Var2: In EspoCRM gelöscht
self.logger.info(f"[KOMM] 🗑️ Var2: Deleted in EspoCRM")
diff['espo_deleted'].append(komm)
def _analyze_advoware_without_marker(
self, advo_without_marker: List[Dict], espo_values: Dict,
is_initial_sync: bool, advo_bet: Dict, diff: Dict
) -> None:
"""Analysiert Advoware-Einträge OHNE Marker für Var4 + Initial Sync Matching"""
# FIX BUG-3: Bei Initial Sync Value-Map erstellen
advo_values_without_marker = {}
if is_initial_sync:
advo_values_without_marker = {
(k.get('tlf') or '').strip(): k
for k in advo_without_marker
if (k.get('tlf') or '').strip()
}
# Sammle matched values für Initial Sync
matched_komm_ids = set()
# Prüfe ob EspoCRM-Werte bereits in Advoware existieren (Initial Sync)
if is_initial_sync:
for value in espo_values.keys():
if value in advo_values_without_marker:
matched_komm = advo_values_without_marker[value]
espo_item = espo_values[value]
# Match gefunden - setze nur Marker, kein Var1/Var4
if 'initial_sync_matches' not in diff:
diff['initial_sync_matches'] = []
diff['initial_sync_matches'].append((value, matched_komm, espo_item))
matched_komm_ids.add(matched_komm['id'])
self.logger.info(f"[KOMM] ✓ Initial Sync Match: '{value[:30]}...'")
# Var4: Neu in Advoware (nicht matched im Initial Sync)
for komm in advo_without_marker:
if komm['id'] not in matched_komm_ids:
tlf = (komm.get('tlf') or '').strip()
self.logger.info(f"[KOMM] Var4: New in Advoware - '{tlf[:30]}...'")
diff['advo_new'].append(komm)
def _analyze_espocrm_only(
self, espo_values: Dict, advo_with_marker: Dict,
espo_wins: bool, espo_changed_since_sync: bool,
advo_changed_since_sync: bool, diff: Dict
) -> None:
"""Analysiert EspoCRM-Einträge die nicht in Advoware sind für Var1/Var3"""
# Sammle bereits gematchte values aus Initial Sync
matched_values = set()
if 'initial_sync_matches' in diff:
matched_values = {v for v, k, e in diff['initial_sync_matches']}
for value, espo_item in espo_values.items():
# Skip wenn bereits im Initial Sync gematched
if value in matched_values:
continue
# Skip wenn in Advoware mit Marker
if value in advo_with_marker:
continue
# Hash-basierte Logik: Var1 vs Var3
if espo_wins or (espo_changed_since_sync and not advo_changed_since_sync):
# Var1: Neu in EspoCRM
self.logger.info(f"[KOMM] Var1: New in EspoCRM '{value[:30]}...'")
diff['espo_new'].append((value, espo_item))
elif advo_changed_since_sync and not espo_changed_since_sync:
# Var3: In Advoware gelöscht
self.logger.info(f"[KOMM] 🗑️ Var3: Deleted in Advoware '{value[:30]}...'")
diff['advo_deleted'].append((value, espo_item))
else:
# Default: Var1 (neu in EspoCRM)
self.logger.info(f"[KOMM] Var1 (default): '{value[:30]}...'")
diff['espo_new'].append((value, espo_item))
# ========== APPLY CHANGES ==========
async def _apply_advoware_to_espocrm(self, beteiligte_id: str, diff: Dict,
advo_bet: Dict) -> Dict[str, Any]:
"""
Wendet Advoware-Änderungen auf EspoCRM an (Var4, Var6)
"""
result = {'emails_synced': 0, 'phones_synced': 0, 'markers_updated': 0, 'errors': []}
try:
# Lade aktuelle EspoCRM Daten
espo_bet = await self.espocrm.get_entity('CBeteiligte', beteiligte_id)
espo_emails = list(espo_bet.get('emailAddressData', []))
espo_phones = list(espo_bet.get('phoneNumberData', []))
# Var6: Advoware-Änderungen → Update Marker + Sync zu EspoCRM
for komm, old_value, new_value in diff['advo_changed']:
self.logger.info(f"[KOMM] Var6: Advoware changed '{old_value}''{new_value}'")
# Update Marker in Advoware
bemerkung = komm.get('bemerkung') or ''
marker = parse_marker(bemerkung)
user_text = marker.get('user_text', '') if marker else ''
kommkz = marker['kommKz'] if marker else detect_kommkz(new_value, advo_bet)
new_marker = create_marker(new_value, kommkz, user_text)
await self.advoware.update_kommunikation(advo_bet['betNr'], komm['id'], {
'bemerkung': new_marker
})
result['markers_updated'] += 1
# Update in EspoCRM: Finde alten Wert und ersetze mit neuem
if is_email_type(kommkz):
for i, email in enumerate(espo_emails):
if email.get('emailAddress') == old_value:
espo_emails[i] = {
'emailAddress': new_value,
'lower': new_value.lower(),
'primary': komm.get('online', False),
'optOut': False,
'invalid': False
}
result['emails_synced'] += 1
break
else:
for i, phone in enumerate(espo_phones):
if phone.get('phoneNumber') == old_value:
type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'}
espo_phones[i] = {
'phoneNumber': new_value,
'type': type_map.get(kommkz, 'Other'),
'primary': komm.get('online', False),
'optOut': False,
'invalid': False
}
result['phones_synced'] += 1
break
# Var4: Neu in Advoware → Zu EspoCRM hinzufügen + Marker setzen
for komm in diff['advo_new']:
tlf = (komm.get('tlf') or '').strip()
kommkz = detect_kommkz(tlf, advo_bet, komm.get('bemerkung'))
self.logger.info(f"[KOMM] Var4: New in Advoware '{tlf}', syncing to EspoCRM")
# Setze Marker in Advoware
new_marker = create_marker(tlf, kommkz)
await self.advoware.update_kommunikation(advo_bet['betNr'], komm['id'], {
'bemerkung': new_marker
})
# Zu EspoCRM hinzufügen
if is_email_type(kommkz):
espo_emails.append({
'emailAddress': tlf,
'lower': tlf.lower(),
'primary': komm.get('online', False),
'optOut': False,
'invalid': False
})
result['emails_synced'] += 1
else:
type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'}
espo_phones.append({
'phoneNumber': tlf,
'type': type_map.get(kommkz, 'Other'),
'primary': komm.get('online', False),
'optOut': False,
'invalid': False
})
result['phones_synced'] += 1
# Var3: In Advoware gelöscht → Aus EspoCRM entfernen
for value, espo_item in diff.get('advo_deleted', []):
self.logger.info(f"[KOMM] Var3: Deleted in Advoware '{value}', removing from EspoCRM")
if espo_item['is_email']:
espo_emails = [e for e in espo_emails if e.get('emailAddress') != value]
result['emails_synced'] += 1 # Zählt als "synced" (gelöscht)
else:
espo_phones = [p for p in espo_phones if p.get('phoneNumber') != value]
result['phones_synced'] += 1
# Update EspoCRM wenn Änderungen
if result['emails_synced'] > 0 or result['phones_synced'] > 0:
await self.espocrm.update_entity('CBeteiligte', beteiligte_id, {
'emailAddressData': espo_emails,
'phoneNumberData': espo_phones
})
self.logger.info(f"[KOMM] ✅ Updated EspoCRM: {result['emails_synced']} emails, {result['phones_synced']} phones")
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler bei Advoware→EspoCRM Apply: {e}")
self.logger.error(traceback.format_exc())
result['errors'].append(str(e))
return result
async def _apply_espocrm_to_advoware(self, betnr: int, diff: Dict,
advo_bet: Dict) -> Dict[str, Any]:
"""
Wendet EspoCRM-Änderungen auf Advoware an (Var1, Var2, Var3, Var5)
"""
result = {'created': 0, 'updated': 0, 'deleted': 0, 'errors': []}
try:
advo_kommunikationen = advo_bet.get('kommunikation', [])
# OPTIMIERUNG: Matche Var2 (Delete) + Var1 (New) mit gleichem kommKz
# → Direkt UPDATE statt DELETE+RELOAD+CREATE
var2_by_kommkz = {} # kommKz → [komm, ...]
var1_by_kommkz = {} # kommKz → [(value, espo_item), ...]
# Gruppiere Var2 nach kommKz
for komm in diff['espo_deleted']:
bemerkung = komm.get('bemerkung') or ''
marker = parse_marker(bemerkung)
if marker:
kommkz = marker['kommKz']
if kommkz not in var2_by_kommkz:
var2_by_kommkz[kommkz] = []
var2_by_kommkz[kommkz].append(komm)
# Gruppiere Var1 nach kommKz
for value, espo_item in diff['espo_new']:
espo_type = espo_item.get('type', 'email' if '@' in value else None)
kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type)
if kommkz not in var1_by_kommkz:
var1_by_kommkz[kommkz] = []
var1_by_kommkz[kommkz].append((value, espo_item))
# Matche und führe direkte Updates aus
matched_var2_ids = set()
matched_var1_indices = {} # kommkz → set of matched indices
for kommkz in var2_by_kommkz.keys():
if kommkz in var1_by_kommkz:
var2_list = var2_by_kommkz[kommkz]
var1_list = var1_by_kommkz[kommkz]
# Matche paarweise
for i, (value, espo_item) in enumerate(var1_list):
if i < len(var2_list):
komm = var2_list[i]
komm_id = komm['id']
self.logger.info(f"[KOMM] 🔄 Var2+Var1 Match: kommKz={kommkz}, updating slot {komm_id} with '{value[:30]}...'")
# Direktes UPDATE statt DELETE+CREATE
await self.advoware.update_kommunikation(betnr, komm_id, {
'tlf': value,
'online': espo_item['primary'],
'bemerkung': create_marker(value, kommkz)
})
matched_var2_ids.add(komm_id)
if kommkz not in matched_var1_indices:
matched_var1_indices[kommkz] = set()
matched_var1_indices[kommkz].add(i)
result['created'] += 1
self.logger.info(f"[KOMM] ✅ Slot updated (optimized merge)")
# Unmatched Var2: Erstelle Empty Slots
for komm in diff['espo_deleted']:
komm_id = komm.get('id')
if komm_id not in matched_var2_ids:
synced_value = komm.get('_synced_value', '')
self.logger.info(f"[KOMM] 🗑️ Var2: Deleted in EspoCRM - komm_id={komm_id}, synced_value='{synced_value[:30]}...'")
await self._create_empty_slot(betnr, komm, synced_value=synced_value)
result['deleted'] += 1
# Var5: In EspoCRM geändert (z.B. primary Flag)
for value, advo_komm, espo_item in diff['espo_changed']:
self.logger.info(f"[KOMM] ✏️ Var5: EspoCRM changed '{value[:30]}...', primary={espo_item.get('primary')}")
bemerkung = advo_komm.get('bemerkung') or ''
marker = parse_marker(bemerkung)
user_text = marker.get('user_text', '') if marker else ''
# Erkenne kommKz mit espo_type
if marker:
kommkz = marker['kommKz']
self.logger.info(f"[KOMM] kommKz from marker: {kommkz}")
else:
espo_type = espo_item.get('type', 'email' if '@' in value else None)
kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type)
self.logger.info(f"[KOMM] kommKz detected: espo_type={espo_type}, kommKz={kommkz}")
# Update in Advoware
await self.advoware.update_kommunikation(betnr, advo_komm['id'], {
'tlf': value,
'online': espo_item['primary'],
'bemerkung': create_marker(value, kommkz, user_text)
})
self.logger.info(f"[KOMM] ✅ Updated komm_id={advo_komm['id']}, kommKz={kommkz}")
result['updated'] += 1
# Var1: Neu in EspoCRM → Create oder reuse Slot in Advoware
# Überspringe bereits gematchte Einträge (Var2+Var1 merged)
for idx, (value, espo_item) in enumerate(diff['espo_new']):
espo_type = espo_item.get('type', 'email' if '@' in value else None)
kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type)
# Skip wenn bereits als Var2+Var1 Match verarbeitet
if kommkz in matched_var1_indices and idx in matched_var1_indices[kommkz]:
continue
self.logger.info(f"[KOMM] Var1: New in EspoCRM '{value[:30]}...', type={espo_item.get('type')}")
self.logger.info(f"[KOMM] 🔍 kommKz detected: espo_type={espo_type}, kommKz={kommkz}")
# Suche leeren Slot
empty_slot = find_empty_slot(kommkz, advo_kommunikationen)
if empty_slot:
# Reuse Slot
self.logger.info(f"[KOMM] ♻️ Reusing empty slot: slot_id={empty_slot['id']}, kommKz={kommkz}")
await self.advoware.update_kommunikation(betnr, empty_slot['id'], {
'tlf': value,
'online': espo_item['primary'],
'bemerkung': create_marker(value, kommkz)
})
self.logger.info(f"[KOMM] ✅ Slot reused successfully")
else:
# Create new
self.logger.info(f"[KOMM] Creating new kommunikation: kommKz={kommkz}")
await self.advoware.create_kommunikation(betnr, {
'tlf': value,
'kommKz': kommkz,
'online': espo_item['primary'],
'bemerkung': create_marker(value, kommkz)
})
self.logger.info(f"[KOMM] ✅ Created new kommunikation with kommKz={kommkz}")
result['created'] += 1
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler bei EspoCRM→Advoware Apply: {e}")
self.logger.error(traceback.format_exc())
result['errors'].append(str(e))
return result
# ========== HELPER METHODS ==========
async def _create_empty_slot(self, betnr: int, advo_komm: Dict, synced_value: str = None) -> None:
"""
Erstellt leeren Slot für gelöschten Eintrag
Args:
betnr: Beteiligten-Nummer
advo_komm: Kommunikations-Eintrag aus Advoware
synced_value: Optional - Original-Wert aus EspoCRM (nur für Logging)
Verwendet für:
- Var2: In EspoCRM gelöscht (hat Marker)
- Var4 bei Konflikt: Neu in Advoware aber EspoCRM wins (hat KEINEN Marker)
"""
try:
komm_id = advo_komm['id']
tlf = (advo_komm.get('tlf') or '').strip()
bemerkung = advo_komm.get('bemerkung') or ''
marker = parse_marker(bemerkung)
# Bestimme kommKz
if marker:
# Hat Marker (Var2)
kommkz = marker['kommKz']
else:
# Kein Marker (Var4 bei Konflikt) - erkenne kommKz aus Wert
from services.kommunikation_mapper import detect_kommkz
kommkz = detect_kommkz(tlf) if tlf else 1 # Default: TelGesch
self.logger.info(f"[KOMM] Var4 ohne Marker: erkenne kommKz={kommkz} aus Wert '{tlf[:20]}...'")
slot_marker = create_slot_marker(kommkz)
update_data = {
'tlf': '', # Empty Slot = leerer Wert
'bemerkung': slot_marker,
'online': False
}
log_value = synced_value if synced_value else tlf
await self.advoware.update_kommunikation(betnr, komm_id, update_data)
self.logger.info(f"[KOMM] ✅ Created empty slot: komm_id={komm_id}, kommKz={kommkz}, original_value='{log_value[:30]}...'")
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler beim Erstellen von Empty Slot: {e}")
self.logger.error(traceback.format_exc())
async def _revert_advoware_change(
self,
betnr: int,
advo_komm: Dict,
espo_synced_value: str,
advo_current_value: str,
advo_bet: Dict
) -> None:
"""
Revertiert Var6-Änderung in Advoware zurück auf EspoCRM-Wert
Verwendet bei direction='to_advoware' (EspoCRM wins):
- User hat in Advoware geändert
- Aber EspoCRM soll gewinnen
- → Setze Advoware zurück auf EspoCRM-Wert
Args:
advo_komm: Advoware Kommunikation mit Änderung
espo_synced_value: Der Wert der mit EspoCRM synchronisiert war (aus Marker)
advo_current_value: Der neue Wert in Advoware (User-Änderung)
"""
try:
komm_id = advo_komm['id']
bemerkung = advo_komm.get('bemerkung', '')
marker = parse_marker(bemerkung)
if not marker:
self.logger.error(f"[KOMM] Var6 ohne Marker - sollte nicht passieren! komm_id={komm_id}")
return
kommkz = marker['kommKz']
user_text = marker.get('user_text', '')
# Revert: Setze tlf zurück auf EspoCRM-Wert
new_marker = create_marker(espo_synced_value, kommkz, user_text)
update_data = {
'tlf': espo_synced_value,
'bemerkung': new_marker,
'online': advo_komm.get('online', False)
}
await self.advoware.update_kommunikation(betnr, komm_id, update_data)
self.logger.info(f"[KOMM] ✅ Reverted Var6: '{advo_current_value[:30]}...''{espo_synced_value[:30]}...' (komm_id={komm_id})")
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler beim Revert von Var6: {e}")
self.logger.error(traceback.format_exc())
def _needs_update(self, advo_komm: Dict, espo_item: Dict) -> bool:
"""Prüft ob Update nötig ist"""
current_value = (advo_komm.get('tlf') or '').strip()
new_value = espo_item['value'].strip()
current_online = advo_komm.get('online', False)
new_online = espo_item.get('primary', False)
return current_value != new_value or current_online != new_online
async def _update_kommunikation(self, betnr: int, advo_komm: Dict, espo_item: Dict) -> None:
"""Updated Advoware Kommunikation"""
try:
komm_id = advo_komm['id']
value = espo_item['value']
# Erkenne kommKz (sollte aus Marker kommen)
bemerkung = advo_komm.get('bemerkung') or ''
marker = parse_marker(bemerkung)
kommkz = marker['kommKz'] if marker else detect_kommkz(value, espo_type=espo_item.get('type'))
# Behalte User-Bemerkung
user_text = get_user_bemerkung(advo_komm)
new_marker = create_marker(value, kommkz, user_text)
update_data = {
'tlf': value,
'bemerkung': new_marker,
'online': espo_item.get('primary', False)
}
await self.advoware.update_kommunikation(betnr, komm_id, update_data)
self.logger.info(f"[KOMM] ✅ Updated: komm_id={komm_id}, value={value[:30]}...")
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler beim Update: {e}")
self.logger.error(traceback.format_exc())
async def _create_or_reuse_kommunikation(self, betnr: int, espo_item: Dict,
advo_kommunikationen: List[Dict]) -> bool:
"""
Erstellt neue Kommunikation oder nutzt leeren Slot
Returns:
True wenn erfolgreich erstellt/reused
"""
try:
value = espo_item['value']
# Erkenne kommKz mit EspoCRM type
espo_type = espo_item.get('type', 'email' if '@' in value else None)
kommkz = detect_kommkz(value, espo_type=espo_type)
self.logger.info(f"[KOMM] 🔍 kommKz detection: value='{value[:30]}...', espo_type={espo_type}, kommKz={kommkz}")
# Suche leeren Slot mit passendem kommKz
empty_slot = find_empty_slot(kommkz, advo_kommunikationen)
new_marker = create_marker(value, kommkz)
if empty_slot:
# ========== REUSE SLOT ==========
komm_id = empty_slot['id']
self.logger.info(f"[KOMM] ♻️ Reusing empty slot: komm_id={komm_id}, kommKz={kommkz}")
update_data = {
'tlf': value,
'bemerkung': new_marker,
'online': espo_item.get('primary', False)
}
await self.advoware.update_kommunikation(betnr, komm_id, update_data)
self.logger.info(f"[KOMM] ✅ Slot reused successfully: value='{value[:30]}...'")
else:
# ========== CREATE NEW ==========
self.logger.info(f"[KOMM] Creating new kommunikation entry: kommKz={kommkz}")
create_data = {
'tlf': value,
'bemerkung': new_marker,
'kommKz': kommkz,
'online': espo_item.get('primary', False)
}
await self.advoware.create_kommunikation(betnr, create_data)
self.logger.info(f"[KOMM] ✅ Created new: value='{value[:30]}...', kommKz={kommkz}")
return True
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler beim Erstellen/Reuse: {e}")
self.logger.error(traceback.format_exc())
return False
# ========== CHANGE DETECTION ==========
def detect_kommunikation_changes(old_bet: Dict, new_bet: Dict) -> bool:
"""
Erkennt Änderungen in Kommunikationen via rowId
Args:
old_bet: Alte Beteiligte-Daten (mit kommunikation[])
new_bet: Neue Beteiligte-Daten (mit kommunikation[])
Returns:
True wenn Änderungen erkannt
"""
old_komm = old_bet.get('kommunikation', [])
new_komm = new_bet.get('kommunikation', [])
# Check Count
if len(old_komm) != len(new_komm):
return True
# Check rowIds
old_row_ids = {k.get('rowId') for k in old_komm}
new_row_ids = {k.get('rowId') for k in new_komm}
return old_row_ids != new_row_ids
def detect_espocrm_kommunikation_changes(old_data: Dict, new_data: Dict) -> bool:
"""
Erkennt Änderungen in EspoCRM emailAddressData/phoneNumberData
Returns:
True wenn Änderungen erkannt
"""
old_emails = old_data.get('emailAddressData', [])
new_emails = new_data.get('emailAddressData', [])
old_phones = old_data.get('phoneNumberData', [])
new_phones = new_data.get('phoneNumberData', [])
# Einfacher Vergleich: Count und Values
if len(old_emails) != len(new_emails) or len(old_phones) != len(new_phones):
return True
old_email_values = {e.get('emailAddress') for e in old_emails}
new_email_values = {e.get('emailAddress') for e in new_emails}
old_phone_values = {p.get('phoneNumber') for p in old_phones}
new_phone_values = {p.get('phoneNumber') for p in new_phones}
return old_email_values != new_email_values or old_phone_values != new_phone_values

View File

@@ -0,0 +1,218 @@
"""LangChain xAI Integration Service
Service für LangChain ChatXAI Integration mit File Search Binding.
Analog zu xai_service.py für xAI Files API.
"""
import os
from typing import Dict, List, Any, Optional, AsyncIterator
from services.logging_utils import get_service_logger
class LangChainXAIService:
"""
Wrapper für LangChain ChatXAI mit Motia-Integration.
Benötigte Umgebungsvariablen:
- XAI_API_KEY: API Key für xAI (für ChatXAI model)
Usage:
service = LangChainXAIService(ctx)
model = service.get_chat_model(model="grok-4-1-fast-reasoning")
model_with_tools = service.bind_file_search(model, collection_id)
result = await service.invoke_chat(model_with_tools, messages)
"""
def __init__(self, ctx=None):
"""
Initialize LangChain xAI Service.
Args:
ctx: Optional Motia context for logging
Raises:
ValueError: If XAI_API_KEY not configured
"""
self.api_key = os.getenv('XAI_API_KEY', '')
self.ctx = ctx
self.logger = get_service_logger('langchain_xai', ctx)
if not self.api_key:
raise ValueError("XAI_API_KEY not configured in environment")
def _log(self, msg: str, level: str = 'info') -> None:
"""Delegate logging to service logger"""
log_func = getattr(self.logger, level, self.logger.info)
log_func(msg)
def get_chat_model(
self,
model: str = "grok-4-1-fast-reasoning",
temperature: float = 0.7,
max_tokens: Optional[int] = None
):
"""
Initialisiert ChatXAI Model.
Args:
model: Model name (default: grok-4-1-fast-reasoning)
temperature: Sampling temperature 0.0-1.0
max_tokens: Optional max tokens for response
Returns:
ChatXAI model instance
Raises:
ImportError: If langchain_xai not installed
"""
try:
from langchain_xai import ChatXAI
except ImportError:
raise ImportError(
"langchain_xai not installed. "
"Run: pip install langchain-xai>=0.2.0"
)
self._log(f"🤖 Initializing ChatXAI: model={model}, temp={temperature}")
kwargs = {
"model": model,
"api_key": self.api_key,
"temperature": temperature
}
if max_tokens:
kwargs["max_tokens"] = max_tokens
return ChatXAI(**kwargs)
def bind_tools(
self,
model,
collection_id: Optional[str] = None,
enable_web_search: bool = False,
web_search_config: Optional[Dict[str, Any]] = None,
max_num_results: int = 10
):
"""
Bindet xAI Tools (file_search und/oder web_search) an Model.
Args:
model: ChatXAI model instance
collection_id: Optional xAI Collection ID für file_search
enable_web_search: Enable web search tool (default: False)
web_search_config: Optional web search configuration:
{
'allowed_domains': ['example.com'], # Max 5 domains
'excluded_domains': ['spam.com'], # Max 5 domains
'enable_image_understanding': True
}
max_num_results: Max results from file search (default: 10)
Returns:
Model with requested tools bound (file_search and/or web_search)
"""
tools = []
# Add file_search tool if collection_id provided
if collection_id:
self._log(f"🔍 Binding file_search: collection={collection_id}")
tools.append({
"type": "file_search",
"vector_store_ids": [collection_id],
"max_num_results": max_num_results
})
# Add web_search tool if enabled
if enable_web_search:
self._log("🌐 Binding web_search")
web_search_tool = {"type": "web_search"}
# Add optional web search filters
if web_search_config:
if 'allowed_domains' in web_search_config:
domains = web_search_config['allowed_domains'][:5] # Max 5
web_search_tool['filters'] = {'allowed_domains': domains}
self._log(f" Allowed domains: {domains}")
elif 'excluded_domains' in web_search_config:
domains = web_search_config['excluded_domains'][:5] # Max 5
web_search_tool['filters'] = {'excluded_domains': domains}
self._log(f" Excluded domains: {domains}")
if web_search_config.get('enable_image_understanding'):
web_search_tool['enable_image_understanding'] = True
self._log(" Image understanding: enabled")
tools.append(web_search_tool)
if not tools:
self._log("⚠️ No tools to bind (no collection_id and web_search disabled)", level='warn')
return model
self._log(f"🔧 Binding {len(tools)} tool(s) to model")
return model.bind_tools(tools)
def bind_file_search(
self,
model,
collection_id: str,
max_num_results: int = 10
):
"""
Legacy method: Bindet nur file_search Tool an Model.
Use bind_tools() for more flexibility.
"""
return self.bind_tools(
model=model,
collection_id=collection_id,
max_num_results=max_num_results
)
async def invoke_chat(
self,
model,
messages: List[Dict[str, Any]]
) -> Any:
"""
Non-streaming Chat Completion.
Args:
model: ChatXAI model (with or without tools)
messages: List of message dicts [{"role": "user", "content": "..."}]
Returns:
LangChain AIMessage with response
Raises:
Exception: If API call fails
"""
self._log(f"💬 Invoking chat: {len(messages)} messages", level='debug')
result = await model.ainvoke(messages)
self._log(f"✅ Response received: {len(result.content)} chars", level='debug')
return result
async def astream_chat(
self,
model,
messages: List[Dict[str, Any]]
) -> AsyncIterator:
"""
Streaming Chat Completion.
Args:
model: ChatXAI model (with or without tools)
messages: List of message dicts
Yields:
Chunks from streaming response
Example:
async for chunk in service.astream_chat(model, messages):
delta = chunk.content if hasattr(chunk, "content") else ""
# Process delta...
"""
self._log(f"💬 Streaming chat: {len(messages)} messages", level='debug')
async for chunk in model.astream(messages):
yield chunk

416
services/logging_utils.py Normal file
View File

@@ -0,0 +1,416 @@
"""
Konsistenter Logging Wrapper für BitByLaw Integration
Vereinheitlicht Logging über:
- Standard Python Logger
- Motia FlowContext Logger
- Structured Logging
Usage Guidelines:
=================
FOR SERVICES: Use get_service_logger('service_name', context)
-----------------------------------------------------------------
Example:
from services.logging_utils import get_service_logger
class XAIService:
def __init__(self, ctx=None):
self.logger = get_service_logger('xai', ctx)
def upload(self):
self.logger.info("Uploading file...")
FOR STEPS: Use ctx.logger directly (preferred)
-----------------------------------------------------------------
Steps already have ctx.logger available - use it directly:
async def handler(event_data, ctx: FlowContext):
ctx.logger.info("Processing event")
Alternative: Use get_step_logger() for additional loggers:
step_logger = get_step_logger('beteiligte_sync', ctx)
FOR SYNC UTILS: Inherit from BaseSyncUtils (provides self.logger)
-----------------------------------------------------------------
from services.sync_utils_base import BaseSyncUtils
class MySync(BaseSyncUtils):
def __init__(self, espocrm, redis, context):
super().__init__(espocrm, redis, context)
# self.logger is now available
def sync(self):
self._log("Syncing...", level='info')
FOR STANDALONE UTILITIES: Use get_logger()
-----------------------------------------------------------------
from services.logging_utils import get_logger
logger = get_logger('my_module', context)
logger.info("Processing...")
CONSISTENCY RULES:
==================
✅ Services: get_service_logger('service_name', ctx)
✅ Steps: ctx.logger (direct) or get_step_logger('step_name', ctx)
✅ Sync Utils: Inherit from BaseSyncUtils → use self._log() or self.logger
✅ Standalone: get_logger('module_name', ctx)
❌ DO NOT: Use module-level logging.getLogger(__name__)
❌ DO NOT: Mix get_logger() and get_service_logger() in same module
"""
import logging
import time
from typing import Optional, Any, Dict
from contextlib import contextmanager
from datetime import datetime
class IntegrationLogger:
"""
Unified Logger mit Support für:
- Motia FlowContext
- Standard Python Logging
- Structured Logging
- Performance Tracking
"""
def __init__(
self,
name: str,
context: Optional[Any] = None,
extra_fields: Optional[Dict[str, Any]] = None
):
"""
Initialize logger.
Args:
name: Logger name (z.B. 'advoware.api')
context: Optional Motia FlowContext
extra_fields: Optional extra fields für structured logging
"""
self.name = name
self.context = context
self.extra_fields = extra_fields or {}
self._standard_logger = logging.getLogger(name)
def _format_message(self, message: str, **kwargs) -> str:
"""
Formatiert Log-Message mit optionalen Feldern.
Args:
message: Base message
**kwargs: Extra fields
Returns:
Formatted message
"""
if not kwargs and not self.extra_fields:
return message
# Merge extra fields
fields = {**self.extra_fields, **kwargs}
if fields:
field_str = " | ".join(f"{k}={v}" for k, v in fields.items())
return f"{message} | {field_str}"
return message
def _log(
self,
level: str,
message: str,
exc_info: bool = False,
**kwargs
) -> None:
"""
Internal logging method.
Args:
level: Log level (debug, info, warning, error, critical)
message: Log message
exc_info: Include exception info
**kwargs: Extra fields for structured logging
"""
formatted_msg = self._format_message(message, **kwargs)
# Log to FlowContext if available
if self.context and hasattr(self.context, 'logger'):
try:
log_func = getattr(self.context.logger, level, self.context.logger.info)
log_func(formatted_msg)
except Exception:
# Fallback to standard logger
pass
# Always log to standard Python logger
log_func = getattr(self._standard_logger, level, self._standard_logger.info)
log_func(formatted_msg, exc_info=exc_info)
def debug(self, message: str, **kwargs) -> None:
"""Log debug message"""
self._log('debug', message, **kwargs)
def info(self, message: str, **kwargs) -> None:
"""Log info message"""
self._log('info', message, **kwargs)
def warning(self, message: str, **kwargs) -> None:
"""Log warning message"""
self._log('warning', message, **kwargs)
def warn(self, message: str, **kwargs) -> None:
"""Alias for warning"""
self.warning(message, **kwargs)
def error(self, message: str, exc_info: bool = True, **kwargs) -> None:
"""Log error message (with exception info by default)"""
self._log('error', message, exc_info=exc_info, **kwargs)
def critical(self, message: str, exc_info: bool = True, **kwargs) -> None:
"""Log critical message"""
self._log('critical', message, exc_info=exc_info, **kwargs)
def exception(self, message: str, **kwargs) -> None:
"""Log exception with traceback"""
self._log('error', message, exc_info=True, **kwargs)
@contextmanager
def operation(self, operation_name: str, **context_fields):
"""
Context manager für Operations mit automatischem Timing.
Args:
operation_name: Name der Operation
**context_fields: Context fields für logging
Example:
with logger.operation('sync_beteiligte', entity_id='123'):
# Do sync
pass
"""
start_time = time.time()
self.info(f"▶️ Starting: {operation_name}", **context_fields)
try:
yield
duration_ms = int((time.time() - start_time) * 1000)
self.info(
f"✅ Completed: {operation_name}",
duration_ms=duration_ms,
**context_fields
)
except Exception as e:
duration_ms = int((time.time() - start_time) * 1000)
self.error(
f"❌ Failed: {operation_name} - {str(e)}",
duration_ms=duration_ms,
error_type=type(e).__name__,
**context_fields
)
raise
@contextmanager
def api_call(self, endpoint: str, method: str = 'GET', **context_fields):
"""
Context manager speziell für API-Calls.
Args:
endpoint: API endpoint
method: HTTP method
**context_fields: Extra context
Example:
with logger.api_call('/api/v1/Beteiligte', method='POST'):
result = await api.post(...)
"""
start_time = time.time()
self.debug(f"API Call: {method} {endpoint}", **context_fields)
try:
yield
duration_ms = int((time.time() - start_time) * 1000)
self.debug(
f"API Success: {method} {endpoint}",
duration_ms=duration_ms,
**context_fields
)
except Exception as e:
duration_ms = int((time.time() - start_time) * 1000)
self.error(
f"API Error: {method} {endpoint} - {str(e)}",
duration_ms=duration_ms,
error_type=type(e).__name__,
**context_fields
)
raise
def with_context(self, **extra_fields) -> 'IntegrationLogger':
"""
Erstellt neuen Logger mit zusätzlichen Context-Feldern.
Args:
**extra_fields: Additional context fields
Returns:
New logger instance with merged context
"""
merged_fields = {**self.extra_fields, **extra_fields}
return IntegrationLogger(
name=self.name,
context=self.context,
extra_fields=merged_fields
)
# ========== Factory Functions ==========
def get_logger(
name: str,
context: Optional[Any] = None,
**extra_fields
) -> IntegrationLogger:
"""
Factory function für Logger.
Args:
name: Logger name
context: Optional Motia FlowContext
**extra_fields: Extra context fields
Returns:
Configured logger
Example:
logger = get_logger('advoware.sync', context=ctx, entity_id='123')
logger.info("Starting sync")
"""
return IntegrationLogger(name, context, extra_fields)
def get_service_logger(
service_name: str,
context: Optional[Any] = None
) -> IntegrationLogger:
"""
Factory für Service-Logger.
Args:
service_name: Service name (z.B. 'advoware', 'espocrm')
context: Optional FlowContext
Returns:
Service logger
"""
return IntegrationLogger(f"services.{service_name}", context)
def get_step_logger(
step_name: str,
context: Optional[Any] = None
) -> IntegrationLogger:
"""
Factory für Step-Logger.
Args:
step_name: Step name
context: FlowContext (required for steps)
Returns:
Step logger
"""
return IntegrationLogger(f"steps.{step_name}", context)
# ========== Decorator for Logging ==========
def log_operation(operation_name: str):
"""
Decorator für automatisches Operation-Logging.
Args:
operation_name: Name der Operation
Example:
@log_operation('sync_beteiligte')
async def sync_entity(entity_id: str):
...
"""
def decorator(func):
async def async_wrapper(*args, **kwargs):
# Try to find context in args
context = None
for arg in args:
if hasattr(arg, 'logger'):
context = arg
break
logger = get_logger(func.__module__, context)
with logger.operation(operation_name):
return await func(*args, **kwargs)
def sync_wrapper(*args, **kwargs):
context = None
for arg in args:
if hasattr(arg, 'logger'):
context = arg
break
logger = get_logger(func.__module__, context)
with logger.operation(operation_name):
return func(*args, **kwargs)
# Return appropriate wrapper
import asyncio
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
# ========== Performance Tracking ==========
class PerformanceTracker:
"""Track performance metrics for operations"""
def __init__(self, logger: IntegrationLogger):
self.logger = logger
self.metrics: Dict[str, list] = {}
def record(self, operation: str, duration_ms: int) -> None:
"""Record operation duration"""
if operation not in self.metrics:
self.metrics[operation] = []
self.metrics[operation].append(duration_ms)
def get_stats(self, operation: str) -> Dict[str, float]:
"""Get statistics for operation"""
if operation not in self.metrics:
return {}
durations = self.metrics[operation]
return {
'count': len(durations),
'avg_ms': sum(durations) / len(durations),
'min_ms': min(durations),
'max_ms': max(durations),
'total_ms': sum(durations)
}
def log_summary(self) -> None:
"""Log summary of all operations"""
self.logger.info("=== Performance Summary ===")
for operation, durations in self.metrics.items():
stats = self.get_stats(operation)
self.logger.info(
f"{operation}: {stats['count']} calls, "
f"avg {stats['avg_ms']:.1f}ms, "
f"min {stats['min_ms']:.1f}ms, "
f"max {stats['max_ms']:.1f}ms"
)

315
services/models.py Normal file
View File

@@ -0,0 +1,315 @@
"""
Pydantic Models für Datenvalidierung
Definiert strenge Schemas für:
- Advoware Entities
- EspoCRM Entities
- Sync Operations
"""
from pydantic import BaseModel, Field, field_validator, ConfigDict
from typing import Optional, Literal
from datetime import date, datetime
from enum import Enum
# ========== Enums ==========
class Rechtsform(str, Enum):
"""Legal forms for Beteiligte"""
NATUERLICHE_PERSON = ""
GMBH = "GmbH"
AG = "AG"
GMBH_CO_KG = "GmbH & Co. KG"
KG = "KG"
OHG = "OHG"
EV = "e.V."
EINZELUNTERNEHMEN = "Einzelunternehmen"
FREIBERUFLER = "Freiberufler"
class SyncStatus(str, Enum):
"""Sync status for EspoCRM entities (Beteiligte)"""
PENDING_SYNC = "pending_sync"
SYNCING = "syncing"
CLEAN = "clean"
FAILED = "failed"
CONFLICT = "conflict"
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):
"""Salutation types"""
HERR = "Herr"
FRAU = "Frau"
DIVERS = "Divers"
FIRMA = ""
class AIKnowledgeActivationStatus(str, Enum):
"""Activation status for CAIKnowledge collections"""
NEW = "new" # Collection noch nicht in XAI erstellt
ACTIVE = "active" # Collection aktiv, Sync läuft
PAUSED = "paused" # Collection existiert, aber kein Sync
DEACTIVATED = "deactivated" # Collection aus XAI gelöscht
def __str__(self) -> str:
return self.value
class AIKnowledgeSyncStatus(str, Enum):
"""Sync status for CAIKnowledge"""
UNCLEAN = "unclean" # Änderungen pending
PENDING_SYNC = "pending_sync" # Sync läuft (locked)
SYNCED = "synced" # Alles synced
FAILED = "failed" # Sync fehlgeschlagen
def __str__(self) -> str:
return self.value
class JunctionSyncStatus(str, Enum):
"""Sync status for junction tables (CAIKnowledgeCDokumente)"""
NEW = "new"
UNCLEAN = "unclean"
SYNCED = "synced"
FAILED = "failed"
UNSUPPORTED = "unsupported"
def __str__(self) -> str:
return self.value
# ========== Advoware Models ==========
class AdvowareBeteiligteBase(BaseModel):
"""Base Model für Advoware Beteiligte (POST/PUT)"""
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
name: str = Field(..., min_length=1, max_length=200)
vorname: Optional[str] = Field(None, max_length=100)
rechtsform: str = Field(default="")
anrede: Optional[str] = Field(None, max_length=50)
titel: Optional[str] = Field(None, max_length=50)
bAnrede: Optional[str] = Field(None, max_length=200, description="Briefanrede")
zusatz: Optional[str] = Field(None, max_length=200)
geburtsdatum: Optional[date] = None
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('Name darf nicht leer sein')
return v.strip()
@field_validator('geburtsdatum')
@classmethod
def validate_birthdate(cls, v: Optional[date]) -> Optional[date]:
if v and v > date.today():
raise ValueError('Geburtsdatum kann nicht in der Zukunft liegen')
if v and v.year < 1900:
raise ValueError('Geburtsdatum vor 1900 nicht erlaubt')
return v
class AdvowareBeteiligteRead(AdvowareBeteiligteBase):
"""Advoware Beteiligte Response (GET)"""
betNr: int = Field(..., ge=1)
rowId: str = Field(..., description="Change detection ID")
# Optional fields die Advoware zurückgibt
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
land: Optional[str] = None
class AdvowareBeteiligteCreate(AdvowareBeteiligteBase):
"""Advoware Beteiligte für POST"""
pass
class AdvowareBeteiligteUpdate(AdvowareBeteiligteBase):
"""Advoware Beteiligte für PUT"""
pass
# ========== EspoCRM Models ==========
class EspoCRMBeteiligteBase(BaseModel):
"""Base Model für EspoCRM CBeteiligte"""
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
name: str = Field(..., min_length=1, max_length=255)
firstName: Optional[str] = Field(None, max_length=100)
lastName: Optional[str] = Field(None, max_length=100)
firmenname: Optional[str] = Field(None, max_length=255)
rechtsform: str = Field(default="")
salutationName: Optional[str] = None
titel: Optional[str] = Field(None, max_length=100)
briefAnrede: Optional[str] = Field(None, max_length=255)
zusatz: Optional[str] = Field(None, max_length=255)
dateOfBirth: Optional[date] = None
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('Name darf nicht leer sein')
return v.strip()
@field_validator('dateOfBirth')
@classmethod
def validate_birthdate(cls, v: Optional[date]) -> Optional[date]:
if v and v > date.today():
raise ValueError('Geburtsdatum kann nicht in der Zukunft liegen')
if v and v.year < 1900:
raise ValueError('Geburtsdatum vor 1900 nicht erlaubt')
return v
@field_validator('firstName', 'lastName')
@classmethod
def validate_person_fields(cls, v: Optional[str]) -> Optional[str]:
"""Validiere dass Person-Felder nur bei natürlichen Personen gesetzt sind"""
if v:
return v.strip()
return None
class EspoCRMBeteiligteRead(EspoCRMBeteiligteBase):
"""EspoCRM CBeteiligte Response (GET)"""
id: str = Field(..., min_length=1)
betnr: Optional[int] = Field(None, ge=1)
advowareRowId: Optional[str] = None
syncStatus: SyncStatus = Field(default=SyncStatus.PENDING_SYNC)
syncRetryCount: int = Field(default=0, ge=0, le=10)
syncErrorMessage: Optional[str] = None
advowareLastSync: Optional[datetime] = None
syncNextRetry: Optional[datetime] = None
syncAutoResetAt: Optional[datetime] = None
class EspoCRMBeteiligteCreate(EspoCRMBeteiligteBase):
"""EspoCRM CBeteiligte für POST"""
syncStatus: SyncStatus = Field(default=SyncStatus.PENDING_SYNC)
class EspoCRMBeteiligteUpdate(BaseModel):
"""EspoCRM CBeteiligte für PUT (alle Felder optional)"""
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
name: Optional[str] = Field(None, min_length=1, max_length=255)
firstName: Optional[str] = Field(None, max_length=100)
lastName: Optional[str] = Field(None, max_length=100)
firmenname: Optional[str] = Field(None, max_length=255)
rechtsform: Optional[str] = None
salutationName: Optional[str] = None
titel: Optional[str] = Field(None, max_length=100)
briefAnrede: Optional[str] = Field(None, max_length=255)
zusatz: Optional[str] = Field(None, max_length=255)
dateOfBirth: Optional[date] = None
betnr: Optional[int] = Field(None, ge=1)
advowareRowId: Optional[str] = None
syncStatus: Optional[SyncStatus] = None
syncRetryCount: Optional[int] = Field(None, ge=0, le=10)
syncErrorMessage: Optional[str] = Field(None, max_length=2000)
advowareLastSync: Optional[datetime] = None
syncNextRetry: Optional[datetime] = None
def model_dump_clean(self) -> dict:
"""Gibt nur nicht-None Werte zurück (für PATCH-ähnliches Update)"""
return {k: v for k, v in self.model_dump().items() if v is not None}
# ========== Sync Operation Models ==========
class SyncOperation(BaseModel):
"""Model für Sync-Operation Tracking"""
entity_id: str
action: Literal["create", "update", "delete", "sync_check"]
source: Literal["webhook", "cron", "api", "manual"]
timestamp: datetime = Field(default_factory=datetime.utcnow)
entity_type: str = "CBeteiligte"
class SyncResult(BaseModel):
"""Result einer Sync-Operation"""
success: bool
entity_id: str
action: str
message: Optional[str] = None
error: Optional[str] = None
details: Optional[dict] = None
duration_ms: Optional[int] = None
# ========== Validation Helpers ==========
def validate_beteiligte_advoware(data: dict) -> AdvowareBeteiligteCreate:
"""
Validiert Advoware Beteiligte Daten.
Args:
data: Dict mit Advoware Daten
Returns:
Validiertes Model
Raises:
ValidationError: Bei Validierungsfehlern
"""
try:
return AdvowareBeteiligteCreate.model_validate(data)
except Exception as e:
from services.exceptions import ValidationError
raise ValidationError(f"Invalid Advoware data: {e}")
def validate_beteiligte_espocrm(data: dict) -> EspoCRMBeteiligteCreate:
"""
Validiert EspoCRM Beteiligte Daten.
Args:
data: Dict mit EspoCRM Daten
Returns:
Validiertes Model
Raises:
ValidationError: Bei Validierungsfehlern
"""
try:
return EspoCRMBeteiligteCreate.model_validate(data)
except Exception as e:
from services.exceptions import ValidationError
raise ValidationError(f"Invalid EspoCRM data: {e}")

View File

@@ -0,0 +1,438 @@
"""
Zentrale Notification-Utilities für manuelle Eingriffe
=======================================================
Wenn Advoware-API-Limitierungen existieren (z.B. READ-ONLY Felder),
werden Notifications in EspoCRM erstellt, damit User manuelle Eingriffe
vornehmen können.
Features:
- Notifications an assigned Users
- Task-Erstellung für manuelle Eingriffe
- Zentrale Verwaltung aller Notification-Types
"""
from typing import Dict, Any, Optional, Literal, List
from datetime import datetime, timedelta
import logging
class NotificationManager:
"""
Zentrale Klasse für Notifications bei Sync-Problemen
"""
def __init__(self, espocrm_api, context=None):
"""
Args:
espocrm_api: EspoCRMAPI instance
context: Optional context für Logging
"""
self.espocrm = espocrm_api
self.context = context
self.logger = context.logger if context else logging.getLogger(__name__)
async def notify_manual_action_required(
self,
entity_type: str,
entity_id: str,
action_type: Literal[
"address_delete_required",
"address_reactivate_required",
"address_field_update_required",
"readonly_field_conflict",
"missing_in_advoware",
"sync_conflict",
"entity_deleted_in_source",
"general_manual_action"
],
details: Dict[str, Any],
assigned_user_id: Optional[str] = None,
create_task: bool = True
) -> Dict[str, str]:
"""
Erstellt Notification und optional Task für manuelle Eingriffe
Args:
entity_type: EspoCRM Entity Type (z.B. 'CAdressen', 'CBeteiligte')
entity_id: Entity ID in EspoCRM
action_type: Art der manuellen Aktion
details: Detaillierte Informationen
assigned_user_id: User der benachrichtigt werden soll (optional)
create_task: Ob zusätzlich ein Task erstellt werden soll
Returns:
Dict mit notification_id und optional task_id
"""
try:
# Hole Entity-Daten
entity = await self.espocrm.get_entity(entity_type, entity_id)
entity_name = entity.get('name', f"{entity_type} {entity_id}")
# Falls kein assigned_user, versuche aus Entity zu holen
if not assigned_user_id:
assigned_user_id = entity.get('assignedUserId')
# Erstelle Notification
notification_data = self._build_notification_message(
action_type, entity_type, entity_name, details
)
notification_id = await self._create_notification(
user_id=assigned_user_id,
message=notification_data['message'],
entity_type=entity_type,
entity_id=entity_id
)
result = {'notification_id': notification_id}
# Optional: Task erstellen
if create_task:
task_id = await self._create_task(
name=notification_data['task_name'],
description=notification_data['task_description'],
parent_type=entity_type,
parent_id=entity_id,
assigned_user_id=assigned_user_id,
priority=notification_data['priority']
)
result['task_id'] = task_id
self.logger.info(
f"Manual action notification created: {action_type} for "
f"{entity_type}/{entity_id}"
)
return result
except Exception as e:
self.logger.error(f"Failed to create notification: {e}")
raise
def _build_notification_message(
self,
action_type: str,
entity_type: str,
entity_name: str,
details: Dict[str, Any]
) -> Dict[str, str]:
"""
Erstellt Notification-Message basierend auf Action-Type
Returns:
Dict mit 'message', 'task_name', 'task_description', 'priority'
"""
if action_type == "address_delete_required":
return {
'message': (
f"🗑️ Adresse in Advoware löschen erforderlich\n"
f"Adresse: {entity_name}\n"
f"Grund: Advoware API unterstützt kein DELETE und gueltigBis ist READ-ONLY\n"
f"Bitte manuell in Advoware löschen oder deaktivieren."
),
'task_name': f"Adresse in Advoware löschen: {entity_name}",
'task_description': (
f"MANUELLE AKTION ERFORDERLICH\n\n"
f"Adresse: {entity_name}\n"
f"BetNr: {details.get('betnr', 'N/A')}\n"
f"Adresse: {details.get('strasse', '')}, {details.get('plz', '')} {details.get('ort', '')}\n\n"
f"GRUND:\n"
f"- DELETE API nicht verfügbar (403 Forbidden)\n"
f"- gueltigBis ist READ-ONLY (kann nicht nachträglich gesetzt werden)\n\n"
f"AKTION:\n"
f"1. In Advoware Web-Interface einloggen\n"
f"2. Beteiligten mit BetNr {details.get('betnr', 'N/A')} öffnen\n"
f"3. Adresse suchen: {details.get('strasse', '')}\n"
f"4. Adresse löschen oder deaktivieren\n\n"
f"Nach Erledigung: Task als 'Completed' markieren."
),
'priority': 'Normal'
}
elif action_type == "address_reactivate_required":
return {
'message': (
f"♻️ Adresse-Reaktivierung in Advoware erforderlich\n"
f"Adresse: {entity_name}\n"
f"Grund: gueltigBis kann nicht nachträglich geändert werden\n"
f"Bitte neue Adresse in Advoware erstellen."
),
'task_name': f"Neue Adresse in Advoware erstellen: {entity_name}",
'task_description': (
f"MANUELLE AKTION ERFORDERLICH\n\n"
f"Adresse: {entity_name}\n"
f"BetNr: {details.get('betnr', 'N/A')}\n\n"
f"GRUND:\n"
f"Diese Adresse wurde reaktiviert, aber die alte Adresse in Advoware "
f"ist abgelaufen (gueltigBis in Vergangenheit). Da gueltigBis READ-ONLY ist, "
f"muss eine neue Adresse erstellt werden.\n\n"
f"AKTION:\n"
f"1. In Advoware Web-Interface einloggen\n"
f"2. Beteiligten mit BetNr {details.get('betnr', 'N/A')} öffnen\n"
f"3. Neue Adresse erstellen:\n"
f" - Straße: {details.get('strasse', '')}\n"
f" - PLZ: {details.get('plz', '')}\n"
f" - Ort: {details.get('ort', '')}\n"
f" - Land: {details.get('land', '')}\n"
f" - Bemerkung: EspoCRM-ID: {details.get('espocrm_id', '')}\n"
f"4. Sync erneut durchführen, damit Mapping aktualisiert wird\n\n"
f"Nach Erledigung: Task als 'Completed' markieren."
),
'priority': 'Normal'
}
elif action_type == "address_field_update_required":
readonly_fields = details.get('readonly_fields', [])
return {
'message': (
f"⚠️ Adressfelder in Advoware können nicht aktualisiert werden\n"
f"Adresse: {entity_name}\n"
f"READ-ONLY Felder: {', '.join(readonly_fields)}\n"
f"Bitte manuell in Advoware ändern."
),
'task_name': f"Adressfelder in Advoware aktualisieren: {entity_name}",
'task_description': (
f"MANUELLE AKTION ERFORDERLICH\n\n"
f"Adresse: {entity_name}\n"
f"BetNr: {details.get('betnr', 'N/A')}\n\n"
f"GRUND:\n"
f"Folgende Felder sind in Advoware API READ-ONLY und können nicht "
f"via PUT geändert werden:\n"
f"- {', '.join(readonly_fields)}\n\n"
f"GEWÜNSCHTE ÄNDERUNGEN:\n" +
'\n'.join([f" - {k}: {v}" for k, v in details.get('changes', {}).items()]) +
f"\n\nAKTION:\n"
f"1. In Advoware Web-Interface einloggen\n"
f"2. Beteiligten mit BetNr {details.get('betnr', 'N/A')} öffnen\n"
f"3. Adresse suchen und obige Felder manuell ändern\n"
f"4. Sync erneut durchführen zur Bestätigung\n\n"
f"Nach Erledigung: Task als 'Completed' markieren."
),
'priority': 'Low'
}
elif action_type == "readonly_field_conflict":
return {
'message': (
f"⚠️ Sync-Konflikt bei READ-ONLY Feldern\n"
f"{entity_type}: {entity_name}\n"
f"Änderungen konnten nicht synchronisiert werden."
),
'task_name': f"Sync-Konflikt prüfen: {entity_name}",
'task_description': (
f"SYNC-KONFLIKT\n\n"
f"{entity_type}: {entity_name}\n\n"
f"PROBLEM:\n"
f"Felder wurden in EspoCRM geändert, sind aber in Advoware READ-ONLY.\n\n"
f"BETROFFENE FELDER:\n" +
'\n'.join([f" - {k}: {v}" for k, v in details.get('conflicts', {}).items()]) +
f"\n\nOPTIONEN:\n"
f"1. Änderungen in EspoCRM rückgängig machen (Advoware = Master)\n"
f"2. Änderungen manuell in Advoware vornehmen\n"
f"3. Feld als 'nicht synchronisiert' akzeptieren\n\n"
f"Nach Entscheidung: Task als 'Completed' markieren."
),
'priority': 'Normal'
}
elif action_type == "sync_conflict":
return {
'message': (
f"⚠️ Sync-Konflikt\n"
f"{entity_type}: {entity_name}\n"
f"{details.get('message', 'Beide Systeme haben Änderungen')}"
),
'task_name': f"Sync-Konflikt: {entity_name}",
'task_description': details.get('description', 'Keine Details verfügbar'),
'priority': details.get('priority', 'Normal')
}
elif action_type == "entity_deleted_in_source":
return {
'message': (
f"🗑️ Element in Quellsystem gelöscht\n"
f"{entity_type}: {entity_name}\n"
f"{details.get('message', 'Wurde im Zielsystem gelöscht')}"
),
'task_name': f"Gelöscht: {entity_name}",
'task_description': details.get('description', 'Element wurde gelöscht'),
'priority': details.get('priority', 'High')
}
elif action_type == "missing_in_advoware":
return {
'message': (
f"❓ Element fehlt in Advoware\n"
f"{entity_type}: {entity_name}\n"
f"Bitte manuell in Advoware erstellen."
),
'task_name': f"In Advoware erstellen: {entity_name}",
'task_description': (
f"MANUELLE AKTION ERFORDERLICH\n\n"
f"{entity_type}: {entity_name}\n\n"
f"GRUND:\n"
f"Dieses Element existiert in EspoCRM, aber nicht in Advoware.\n"
f"Möglicherweise wurde es direkt in EspoCRM erstellt.\n\n"
f"DATEN:\n" +
'\n'.join([f" - {k}: {v}" for k, v in details.items() if k != 'espocrm_id']) +
f"\n\nAKTION:\n"
f"1. In Advoware Web-Interface einloggen\n"
f"2. Element mit obigen Daten manuell erstellen\n"
f"3. Sync erneut durchführen für Mapping\n\n"
f"Nach Erledigung: Task als 'Completed' markieren."
),
'priority': 'Normal'
}
else: # general_manual_action
return {
'message': (
f"🔧 Manuelle Aktion erforderlich\n"
f"{entity_type}: {entity_name}\n"
f"{details.get('message', 'Bitte prüfen.')}"
),
'task_name': f"Manuelle Aktion: {entity_name}",
'task_description': (
f"MANUELLE AKTION ERFORDERLICH\n\n"
f"{entity_type}: {entity_name}\n\n"
f"{details.get('description', 'Keine Details verfügbar.')}"
),
'priority': details.get('priority', 'Normal')
}
async def _create_notification(
self,
user_id: Optional[str],
message: str,
entity_type: str,
entity_id: str
) -> str:
"""
Erstellt EspoCRM Notification (In-App)
Returns:
notification_id
"""
if not user_id:
self.logger.warning("No user assigned - notification not created")
return None
notification_data = {
'type': 'Message',
'message': message,
'userId': user_id,
'relatedType': entity_type,
'relatedId': entity_id,
'read': False
}
try:
result = await self.espocrm.create_entity('Notification', notification_data)
return result.get('id')
except Exception as e:
self.logger.error(f"Failed to create notification: {e}")
return None
async def _create_task(
self,
name: str,
description: str,
parent_type: str,
parent_id: str,
assigned_user_id: Optional[str],
priority: str = 'Normal'
) -> str:
"""
Erstellt EspoCRM Task
Returns:
task_id
"""
# Due Date: 7 Tage in Zukunft
due_date = (datetime.now() + timedelta(days=7)).strftime('%Y-%m-%d')
task_data = {
'name': name,
'description': description,
'status': 'Not Started',
'priority': priority,
'dateEnd': due_date,
'parentType': parent_type,
'parentId': parent_id,
'assignedUserId': assigned_user_id
}
try:
result = await self.espocrm.create_entity('Task', task_data)
return result.get('id')
except Exception as e:
self.logger.error(f"Failed to create task: {e}")
return None
async def resolve_task(self, task_id: str) -> bool:
"""
Markiert Task als erledigt
Args:
task_id: Task ID
Returns:
True wenn erfolgreich
"""
try:
await self.espocrm.update_entity('Task', task_id, {
'status': 'Completed'
})
return True
except Exception as e:
self.logger.error(f"Failed to complete task {task_id}: {e}")
return False
# Helper-Funktionen für häufige Use-Cases
async def notify_address_delete_required(
notification_manager: NotificationManager,
address_entity_id: str,
betnr: str,
address_data: Dict[str, Any]
) -> Dict[str, str]:
"""
Shortcut: Notification für Adresse löschen
"""
return await notification_manager.notify_manual_action_required(
entity_type='CAdressen',
entity_id=address_entity_id,
action_type='address_delete_required',
details={
'betnr': betnr,
'strasse': address_data.get('adresseStreet'),
'plz': address_data.get('adressePostalCode'),
'ort': address_data.get('adresseCity'),
'espocrm_id': address_entity_id
}
)
async def notify_address_readonly_fields(
notification_manager: NotificationManager,
address_entity_id: str,
betnr: str,
readonly_fields: List[str],
changes: Dict[str, Any]
) -> Dict[str, str]:
"""
Shortcut: Notification für READ-ONLY Felder
"""
return await notification_manager.notify_manual_action_required(
entity_type='CAdressen',
entity_id=address_entity_id,
action_type='address_field_update_required',
details={
'betnr': betnr,
'readonly_fields': readonly_fields,
'changes': changes
}
)

202
services/redis_client.py Normal file
View File

@@ -0,0 +1,202 @@
"""
Redis Client Factory
Centralized Redis client management with:
- Singleton pattern
- Connection pooling
- Automatic reconnection
- Health checks
"""
import redis
import os
from typing import Optional
from services.exceptions import RedisConnectionError
from services.logging_utils import get_service_logger
class RedisClientFactory:
"""
Singleton factory for Redis clients.
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]:
"""
Return Redis client (creates if needed).
Args:
strict: If True, raises exception on connection failures.
If False, returns None (for optional Redis usage).
Returns:
Redis client or None (if strict=False and connection fails)
Raises:
RedisConnectionError: If strict=True and connection fails
"""
logger = cls._get_logger()
if cls._instance is None:
try:
cls._instance = cls._create_client()
logger.info("Redis client created successfully")
except Exception as e:
logger.error(f"Failed to create Redis client: {e}")
if strict:
raise RedisConnectionError(
f"Could not connect to Redis: {e}",
operation="get_client"
)
logger.warning("Redis unavailable - continuing without caching")
return None
return cls._instance
@classmethod
def _create_client(cls) -> redis.Redis:
"""
Create new Redis client with connection pool.
Returns:
Configured Redis client
Raises:
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'))
redis_db = int(os.getenv('REDIS_DB_ADVOWARE_CACHE', '1'))
redis_timeout = int(os.getenv('REDIS_TIMEOUT_SECONDS', '5'))
redis_max_connections = int(os.getenv('REDIS_MAX_CONNECTIONS', '50'))
logger.info(
f"Creating Redis client: {redis_host}:{redis_port} "
f"(db={redis_db}, timeout={redis_timeout}s)"
)
# Create connection pool
if cls._connection_pool is None:
cls._connection_pool = redis.ConnectionPool(
host=redis_host,
port=redis_port,
db=redis_db,
socket_timeout=redis_timeout,
socket_connect_timeout=redis_timeout,
max_connections=redis_max_connections,
decode_responses=True # Auto-decode bytes to strings
)
# Create client from pool
client = redis.Redis(connection_pool=cls._connection_pool)
# Verify connection
client.ping()
return client
@classmethod
def reset(cls) -> None:
"""
Reset factory state (mainly for tests).
Closes existing connections and resets singleton.
"""
logger = cls._get_logger()
if cls._instance:
try:
cls._instance.close()
except Exception as e:
logger.warning(f"Error closing Redis client: {e}")
if cls._connection_pool:
try:
cls._connection_pool.disconnect()
except Exception as e:
logger.warning(f"Error closing connection pool: {e}")
cls._instance = None
cls._connection_pool = None
logger.info("Redis factory reset")
@classmethod
def health_check(cls) -> bool:
"""
Check Redis connection.
Returns:
True if Redis is reachable, False otherwise
"""
logger = cls._get_logger()
try:
client = cls.get_client(strict=False)
if client is None:
return False
client.ping()
return True
except Exception as e:
logger.warning(f"Redis health check failed: {e}")
return False
@classmethod
def get_info(cls) -> Optional[dict]:
"""
Return Redis server info (for monitoring).
Returns:
Redis info dict or None on error
"""
logger = cls._get_logger()
try:
client = cls.get_client(strict=False)
if client is None:
return None
return client.info()
except Exception as e:
logger.error(f"Failed to get Redis info: {e}")
return None
# ========== Convenience Functions ==========
def get_redis_client(strict: bool = False) -> Optional[redis.Redis]:
"""
Convenience function for Redis client.
Args:
strict: If True, raises exception on error
Returns:
Redis client or None
"""
return RedisClientFactory.get_client(strict=strict)
def is_redis_available() -> bool:
"""
Check if Redis is available.
Returns:
True if Redis is reachable
"""
return RedisClientFactory.health_check()

144
services/sync_utils_base.py Normal file
View File

@@ -0,0 +1,144 @@
"""
Base Sync Utilities
Gemeinsame Funktionalität für alle Sync-Operationen:
- Redis Distributed Locking
- Context-aware Logging
- EspoCRM API Helpers
"""
from typing import Dict, Any, Optional
from datetime import datetime
import pytz
from services.exceptions import RedisConnectionError, LockAcquisitionError
from services.redis_client import get_redis_client
from services.config import SYNC_CONFIG, get_lock_key
from services.logging_utils import get_service_logger
import redis
class BaseSyncUtils:
"""Base-Klasse mit gemeinsamer Sync-Funktionalität"""
def __init__(self, espocrm_api, redis_client: Optional[redis.Redis] = None, context=None):
"""
Args:
espocrm_api: EspoCRM API client instance
redis_client: Optional Redis client (wird sonst über Factory initialisiert)
context: Optional Motia FlowContext für Logging
"""
self.espocrm = espocrm_api
self.context = context
self.logger = get_service_logger('sync_utils', context)
# Use provided Redis client or get from factory
self.redis = redis_client or get_redis_client(strict=False)
if not self.redis:
self.logger.error(
"⚠️ WARNUNG: Redis nicht verfügbar! "
"Distributed Locking deaktiviert - Race Conditions möglich!"
)
def _log(self, message: str, level: str = 'info') -> None:
"""Delegate logging to the logger with optional level"""
log_func = getattr(self.logger, level, self.logger.info)
log_func(message)
def _get_lock_key(self, entity_id: str) -> str:
"""
Erzeugt Redis Lock-Key für eine Entity
Muss in Subklassen überschrieben werden, um entity-spezifische Prefixes zu nutzen.
z.B. 'sync_lock:cbeteiligte:{entity_id}' oder 'sync_lock:document:{entity_id}'
"""
raise NotImplementedError("Subclass must implement _get_lock_key()")
def _acquire_redis_lock(self, lock_key: str) -> bool:
"""
Atomic Redis lock acquisition
Args:
lock_key: Redis key für den Lock
Returns:
True wenn Lock erfolgreich, False wenn bereits locked
Raises:
LockAcquisitionError: Bei kritischen Lock-Problemen (wenn strict mode)
"""
if not self.redis:
self.logger.error(
"CRITICAL: Distributed Locking deaktiviert - Redis nicht verfügbar!"
)
# In production: Dies könnte zu Race Conditions führen!
# Für jetzt erlauben wir Fortsetzung, aber mit Warning
return True
try:
acquired = self.redis.set(
lock_key,
"locked",
nx=True,
ex=SYNC_CONFIG.lock_ttl_seconds
)
return bool(acquired)
except redis.RedisError as e:
self.logger.error(f"Redis lock error: {e}")
# Bei Redis-Fehler: Lock erlauben, um Deadlocks zu vermeiden
return True
def _release_redis_lock(self, lock_key: str) -> None:
"""
Redis lock freigeben
Args:
lock_key: Redis key für den Lock
"""
if not self.redis:
return
try:
self.redis.delete(lock_key)
except redis.RedisError as e:
self.logger.error(f"Redis unlock error: {e}")
def _get_espocrm_datetime(self, dt: Optional[datetime] = None) -> str:
"""
Formatiert datetime für EspoCRM (ohne Timezone!)
Args:
dt: Optional datetime object (default: now UTC)
Returns:
String im Format 'YYYY-MM-DD HH:MM:SS'
"""
if dt is None:
dt = datetime.now(pytz.UTC)
elif dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
return dt.strftime('%Y-%m-%d %H:%M:%S')
async def acquire_sync_lock(self, entity_id: str, **kwargs) -> bool:
"""
Erwirbt Sync-Lock für eine Entity
Muss in Subklassen implementiert werden, um entity-spezifische
Status-Updates durchzuführen.
Returns:
True wenn Lock erfolgreich, False wenn bereits locked
"""
raise NotImplementedError("Subclass must implement acquire_sync_lock()")
async def release_sync_lock(self, entity_id: str, **kwargs) -> None:
"""
Gibt Sync-Lock frei und setzt finalen Status
Muss in Subklassen implementiert werden, um entity-spezifische
Status-Updates durchzuführen.
"""
raise NotImplementedError("Subclass must implement release_sync_lock()")

529
services/xai_service.py Normal file
View File

@@ -0,0 +1,529 @@
"""xAI Files & Collections Service"""
import os
import asyncio
import aiohttp
from typing import Optional, List, Dict, Tuple
from services.logging_utils import get_service_logger
XAI_FILES_URL = "https://api.x.ai"
XAI_MANAGEMENT_URL = "https://management-api.x.ai"
class XAIService:
"""
Client für xAI Files API und Collections Management API.
Benötigte Umgebungsvariablen:
- XAI_API_KEY regulärer API-Key für File-Uploads (api.x.ai)
- XAI_MANAGEMENT_KEY Management-API-Key für Collection-Operationen (management-api.x.ai)
"""
def __init__(self, ctx=None):
self.api_key = os.getenv('XAI_API_KEY', '')
self.management_key = os.getenv('XAI_MANAGEMENT_KEY', '')
self.ctx = ctx
self.logger = get_service_logger('xai', ctx)
self._session: Optional[aiohttp.ClientSession] = None
if not self.api_key:
raise ValueError("XAI_API_KEY not configured in environment")
if not self.management_key:
raise ValueError("XAI_MANAGEMENT_KEY not configured in environment")
def _log(self, msg: str, level: str = 'info') -> None:
"""Delegate logging to service logger"""
log_func = getattr(self.logger, level, self.logger.info)
log_func(msg)
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=120)
)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
async def upload_file(
self,
file_content: bytes,
filename: str,
mime_type: str = 'application/octet-stream'
) -> str:
"""
Lädt eine Datei zur xAI Files API hoch (multipart/form-data).
POST https://api.x.ai/v1/files
Returns:
xAI file_id (str)
Raises:
RuntimeError: bei HTTP-Fehler oder fehlendem file_id in der Antwort
"""
# Normalize MIME type: xAI needs correct Content-Type for proper processing
# If generic octet-stream but file is clearly a PDF, fix it
if mime_type == 'application/octet-stream' and filename.lower().endswith('.pdf'):
mime_type = 'application/pdf'
self._log(f"⚠️ Corrected MIME type to application/pdf for {filename}")
self._log(f"📤 Uploading {len(file_content)} bytes to xAI: {filename} ({mime_type})")
session = await self._get_session()
url = f"{XAI_FILES_URL}/v1/files"
headers = {"Authorization": f"Bearer {self.api_key}"}
# Create multipart form with explicit UTF-8 filename encoding
# aiohttp automatically URL-encodes filenames with special chars,
# but xAI expects raw UTF-8 in the filename parameter
form = aiohttp.FormData(quote_fields=False)
form.add_field(
'file',
file_content,
filename=filename,
content_type=mime_type
)
# CRITICAL: purpose="file_search" enables proper PDF processing
# Without this, xAI throws "internal error" on complex PDFs
form.add_field('purpose', 'file_search')
async with session.post(url, data=form, headers=headers) as response:
try:
data = await response.json()
except Exception:
raw = await response.text()
data = {"_raw": raw}
if response.status not in (200, 201):
raise RuntimeError(
f"xAI file upload failed ({response.status}): {data}"
)
file_id = data.get('id') or data.get('file_id')
if not file_id:
raise RuntimeError(
f"No file_id in xAI upload response: {data}"
)
self._log(f"✅ xAI file uploaded: {file_id}")
return file_id
async def add_to_collection(self, collection_id: str, file_id: str) -> None:
"""
Fügt eine Datei einer xAI-Collection (Vector Store) hinzu.
POST https://api.x.ai/v1/vector_stores/{vector_store_id}/files
Uses the OpenAI-compatible API pattern for adding files to vector stores.
This triggers proper indexing and processing.
Raises:
RuntimeError: bei HTTP-Fehler
"""
self._log(f"📚 Adding file {file_id} to collection {collection_id}")
session = await self._get_session()
# Use the OpenAI-compatible endpoint (not management API)
url = f"{XAI_FILES_URL}/v1/vector_stores/{collection_id}/files"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {"file_id": file_id}
async with session.post(url, json=payload, headers=headers) as response:
if response.status not in (200, 201):
raw = await response.text()
raise RuntimeError(
f"Failed to add file to collection {collection_id} ({response.status}): {raw}"
)
self._log(f"✅ File {file_id} added to collection {collection_id}")
async def remove_from_collection(self, collection_id: str, file_id: str) -> None:
"""
Entfernt eine Datei aus einer xAI-Collection.
Die Datei selbst wird NICHT gelöscht sie kann in anderen Collections sein.
DELETE https://management-api.x.ai/v1/collections/{collection_id}/documents/{file_id}
Raises:
RuntimeError: bei HTTP-Fehler
"""
self._log(f"🗑️ Removing file {file_id} from collection {collection_id}")
session = await self._get_session()
url = f"{XAI_MANAGEMENT_URL}/v1/collections/{collection_id}/documents/{file_id}"
headers = {"Authorization": f"Bearer {self.management_key}"}
async with session.delete(url, headers=headers) as response:
if response.status not in (200, 204):
raw = await response.text()
raise RuntimeError(
f"Failed to remove file from collection {collection_id} ({response.status}): {raw}"
)
self._log(f"✅ File {file_id} removed from collection {collection_id}")
async def add_to_collections(self, collection_ids: List[str], file_id: str) -> List[str]:
"""
Fügt eine Datei zu mehreren Collections hinzu.
Returns:
Liste der erfolgreich hinzugefügten Collection-IDs
"""
added = []
for collection_id in collection_ids:
try:
await self.add_to_collection(collection_id, file_id)
added.append(collection_id)
except Exception as e:
self._log(
f"⚠️ Fehler beim Hinzufügen zu Collection {collection_id}: {e}",
level='warn'
)
return added
async def remove_from_collections(self, collection_ids: List[str], file_id: str) -> None:
"""Entfernt eine Datei aus mehreren Collections (ignoriert Fehler pro Collection)."""
for collection_id in collection_ids:
try:
await self.remove_from_collection(collection_id, file_id)
except Exception as e:
self._log(
f"⚠️ Fehler beim Entfernen aus Collection {collection_id}: {e}",
level='warn'
)
# ========== Collection Management ==========
async def create_collection(
self,
name: str,
metadata: Optional[Dict[str, str]] = None,
field_definitions: Optional[List[Dict]] = None
) -> Dict:
"""
Erstellt eine neue xAI Collection.
POST https://management-api.x.ai/v1/collections
Args:
name: Collection name
metadata: Optional metadata dict
field_definitions: Optional field definitions for metadata fields
Returns:
Collection object mit 'id' field
Raises:
RuntimeError: bei HTTP-Fehler
"""
self._log(f"📚 Creating collection: {name}")
# Standard field definitions für document metadata
if field_definitions is None:
field_definitions = [
{"key": "document_name", "inject_into_chunk": True},
{"key": "description", "inject_into_chunk": True},
{"key": "created_at", "inject_into_chunk": False},
{"key": "modified_at", "inject_into_chunk": False},
{"key": "espocrm_id", "inject_into_chunk": False}
]
session = await self._get_session()
url = f"{XAI_MANAGEMENT_URL}/v1/collections"
headers = {
"Authorization": f"Bearer {self.management_key}",
"Content-Type": "application/json"
}
body = {
"collection_name": name,
"field_definitions": field_definitions
}
# Add metadata if provided
if metadata:
body["metadata"] = metadata
async with session.post(url, json=body, headers=headers) as response:
if response.status not in (200, 201):
raw = await response.text()
raise RuntimeError(
f"Failed to create collection ({response.status}): {raw}"
)
data = await response.json()
# API returns 'collection_id' not 'id'
collection_id = data.get('collection_id') or data.get('id')
self._log(f"✅ Collection created: {collection_id}")
return data
async def get_collection(self, collection_id: str) -> Optional[Dict]:
"""
Holt Collection-Details.
GET https://management-api.x.ai/v1/collections/{collection_id}
Returns:
Collection object or None if not found
Raises:
RuntimeError: bei HTTP-Fehler (außer 404)
"""
self._log(f"📄 Getting collection: {collection_id}")
session = await self._get_session()
url = f"{XAI_MANAGEMENT_URL}/v1/collections/{collection_id}"
headers = {"Authorization": f"Bearer {self.management_key}"}
async with session.get(url, headers=headers) as response:
if response.status == 404:
self._log(f"⚠️ Collection not found: {collection_id}", level='warn')
return None
if response.status not in (200,):
raw = await response.text()
raise RuntimeError(
f"Failed to get collection ({response.status}): {raw}"
)
data = await response.json()
self._log(f"✅ Collection retrieved: {data.get('collection_name', 'N/A')}")
return data
async def delete_collection(self, collection_id: str) -> None:
"""
Löscht eine XAI Collection.
DELETE https://management-api.x.ai/v1/collections/{collection_id}
NOTE: Documents in der Collection werden NICHT gelöscht!
Sie können noch in anderen Collections sein.
Raises:
RuntimeError: bei HTTP-Fehler
"""
self._log(f"🗑️ Deleting collection {collection_id}")
session = await self._get_session()
url = f"{XAI_MANAGEMENT_URL}/v1/collections/{collection_id}"
headers = {"Authorization": f"Bearer {self.management_key}"}
async with session.delete(url, headers=headers) as response:
if response.status not in (200, 204):
raw = await response.text()
raise RuntimeError(
f"Failed to delete collection {collection_id} ({response.status}): {raw}"
)
self._log(f"✅ Collection deleted: {collection_id}")
async def list_collection_documents(self, collection_id: str) -> List[Dict]:
"""
Listet alle Dokumente in einer Collection.
GET https://management-api.x.ai/v1/collections/{collection_id}/documents
Returns:
List von normalized document objects:
[
{
'file_id': 'file_...',
'filename': 'doc.pdf',
'blake3_hash': 'hex_string', # Plain hex, kein prefix
'size_bytes': 12345,
'content_type': 'application/pdf',
'fields': {}, # Custom metadata
'status': 'DOCUMENT_STATUS_...'
}
]
Raises:
RuntimeError: bei HTTP-Fehler
"""
self._log(f"📋 Listing documents in collection {collection_id}")
session = await self._get_session()
url = f"{XAI_MANAGEMENT_URL}/v1/collections/{collection_id}/documents"
headers = {"Authorization": f"Bearer {self.management_key}"}
async with session.get(url, headers=headers) as response:
if response.status not in (200,):
raw = await response.text()
raise RuntimeError(
f"Failed to list documents ({response.status}): {raw}"
)
data = await response.json()
# API gibt Liste zurück oder dict mit 'documents' key
if isinstance(data, list):
raw_documents = data
elif isinstance(data, dict) and 'documents' in data:
raw_documents = data['documents']
else:
raw_documents = []
# Normalize nested structure: file_metadata -> top-level
normalized = []
for doc in raw_documents:
file_meta = doc.get('file_metadata', {})
normalized.append({
'file_id': file_meta.get('file_id'),
'filename': file_meta.get('name'),
'blake3_hash': file_meta.get('hash'), # Plain hex string
'size_bytes': int(file_meta.get('size_bytes', 0)) if file_meta.get('size_bytes') else 0,
'content_type': file_meta.get('content_type'),
'created_at': file_meta.get('created_at'),
'fields': doc.get('fields', {}),
'status': doc.get('status')
})
self._log(f"✅ Listed {len(normalized)} documents")
return normalized
async def get_collection_document(self, collection_id: str, file_id: str) -> Optional[Dict]:
"""
Holt Dokument-Details aus einer XAI Collection.
GET https://management-api.x.ai/v1/collections/{collection_id}/documents/{file_id}
Returns:
Normalized dict mit document info:
{
'file_id': 'file_xyz',
'filename': 'document.pdf',
'blake3_hash': 'hex_string', # Plain hex, kein prefix
'size_bytes': 12345,
'content_type': 'application/pdf',
'fields': {...} # Custom metadata
}
Returns None if not found.
"""
self._log(f"📄 Getting document {file_id} from collection {collection_id}")
session = await self._get_session()
url = f"{XAI_MANAGEMENT_URL}/v1/collections/{collection_id}/documents/{file_id}"
headers = {"Authorization": f"Bearer {self.management_key}"}
async with session.get(url, headers=headers) as response:
if response.status == 404:
return None
if response.status not in (200,):
raw = await response.text()
raise RuntimeError(
f"Failed to get document from collection ({response.status}): {raw}"
)
data = await response.json()
# Normalize nested structure
file_meta = data.get('file_metadata', {})
normalized = {
'file_id': file_meta.get('file_id'),
'filename': file_meta.get('name'),
'blake3_hash': file_meta.get('hash'), # Plain hex
'size_bytes': int(file_meta.get('size_bytes', 0)) if file_meta.get('size_bytes') else 0,
'content_type': file_meta.get('content_type'),
'created_at': file_meta.get('created_at'),
'fields': data.get('fields', {}),
'status': data.get('status')
}
self._log(f"✅ Document info retrieved: {normalized.get('filename', 'N/A')}")
return normalized
async def update_document_metadata(
self,
collection_id: str,
file_id: str,
metadata: Dict[str, str]
) -> None:
"""
Aktualisiert nur Metadaten eines Documents (kein File-Upload).
PATCH https://management-api.x.ai/v1/collections/{collection_id}/documents/{file_id}
Args:
collection_id: XAI Collection ID
file_id: XAI file_id
metadata: Updated metadata fields
Raises:
RuntimeError: bei HTTP-Fehler
"""
self._log(f"📝 Updating metadata for document {file_id}")
session = await self._get_session()
url = f"{XAI_MANAGEMENT_URL}/v1/collections/{collection_id}/documents/{file_id}"
headers = {
"Authorization": f"Bearer {self.management_key}",
"Content-Type": "application/json"
}
body = {"fields": metadata}
async with session.patch(url, json=body, headers=headers) as response:
if response.status not in (200, 204):
raw = await response.text()
raise RuntimeError(
f"Failed to update document metadata ({response.status}): {raw}"
)
self._log(f"✅ Metadata updated for {file_id}")
def is_mime_type_supported(self, mime_type: str) -> bool:
"""
Prüft, ob XAI diesen MIME-Type unterstützt.
Args:
mime_type: MIME type string
Returns:
True wenn unterstützt, False sonst
"""
# Liste der unterstützten MIME-Types basierend auf XAI Dokumentation
supported_types = {
# Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.oasis.opendocument.text',
'application/epub+zip',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
# Text
'text/plain',
'text/html',
'text/markdown',
'text/csv',
'text/xml',
# Code
'text/javascript',
'application/json',
'application/xml',
'text/x-python',
'text/x-java-source',
'text/x-c',
'text/x-c++src',
# Other
'application/zip',
}
# Normalisiere MIME-Type (lowercase, strip whitespace)
normalized = mime_type.lower().strip()
return normalized in supported_types

View File

@@ -0,0 +1,268 @@
# Advoware Calendar Sync - Event-Driven Design
Dieser Abschnitt implementiert die bidirektionale Synchronisation zwischen Advoware-Terminen und Google Calendar. Das System nutzt einen event-driven Ansatz mit **Motia III v1.0-RC**, der auf direkten API-Calls basiert, mit Redis für Locking und Deduplikation. Es stellt sicher, dass Termine konsistent gehalten werden, mit Fokus auf Robustheit, Fehlerbehandlung und korrekte Handhabung von mehrtägigen Terminen.
## Übersicht
Das System synchronisiert Termine zwischen:
- **Advoware**: Zentrale Terminverwaltung mit detaillierten Informationen.
- **Google Calendar**: Benutzerfreundliche Kalenderansicht für jeden Mitarbeiter.
## Architektur
### Event-Driven Design
- **Direkte API-Synchronisation**: Kein zentraler Hub; Sync läuft direkt zwischen APIs.
- **Redis Locking**: Per-Employee Locking verhindert Race-Conditions.
- **Event Emission**: Cron → All-Step → Employee-Step für skalierbare Verarbeitung.
- **Fehlerresistenz**: Einzelne Fehler stoppen nicht den gesamten Sync.
- **Logging**: Alle Logs erscheinen in der iii Console via `ctx.logger`.
### Sync-Phasen
1. **Cron-Step**: Automatische Auslösung alle 15 Minuten.
2. **All-Step**: Fetcht alle Mitarbeiter und emittiert Events pro Employee.
3. **Employee-Step**: Synchronisiert Termine für einen einzelnen Mitarbeiter.
### Datenmapping und Standardisierung
Beide Systeme werden auf gemeinsames Format normalisiert (Berlin TZ):
```python
{
'start': datetime, # Berlin TZ
'end': datetime,
'text': str,
'notiz': str,
'ort': str,
'dauertermin': int, # 0/1
'turnus': int, # 0/1
'turnusArt': int,
'recurrence': str # RRULE oder None
}
```
#### Advoware → Standard
- Start: `datum` + `uhrzeitVon` (Fallback 09:00), oder `datum` als datetime.
- End: `datumBis` + `uhrzeitBis` (Fallback 10:00), oder `datum` + 1h.
- All-Day: `dauertermin=1` oder Dauer >1 Tag.
- Recurring: `turnus`/`turnusArt` (vereinfacht, keine RRULE).
#### Google → Standard
- Start/End: `dateTime` oder `date` (All-Day).
- All-Day: `dauertermin=1` wenn All-Day oder Dauer >1 Tag.
- Recurring: RRULE aus `recurrence`.
#### Standard → Advoware
- POST/PUT: `datum`/`uhrzeitBis`/`datumBis` aus start/end.
- Defaults: `vorbereitungsDauer='00:00:00'`, `sb`/`anwalt`=employee_kuerzel.
#### Standard → Google
- All-Day: `date` statt `dateTime`, end +1 Tag.
- Recurring: RRULE aus `recurrence`.
## Funktionalität
### Automatische Kalender-Erstellung
- Für jeden Advoware-Mitarbeiter wird ein Google Calendar mit dem Namen `AW-{Kuerzel}` erstellt.
- Beispiel: Mitarbeiter mit Kürzel "SB" → Calendar "AW-SB".
- Kalender wird mit dem Haupt-Google-Account (`lehmannundpartner@gmail.com`) als Owner geteilt.
### Sync-Details
#### Cron-Step (calendar_sync_cron_step.py)
- Läuft alle 15 Minuten und emittiert "calendar_sync_all".
- **Trigger**: `cron("0 */15 * * * *")` (6-field: Sekunde Minute Stunde Tag Monat Wochentag)
#### All-Step (calendar_sync_all_step.py)
- Fetcht alle Mitarbeiter aus Advoware.
- Filtert Debug-Liste (falls konfiguriert).
- Setzt Redis-Lock pro Employee.
- Emittiert "calendar_sync" Event pro Employee.
- **Trigger**: `queue('calendar_sync_all')`
#### Employee-Step (calendar_sync_event_step.py)
- Fetcht Advoware-Termine für den Employee.
- Fetcht Google-Events für den Employee.
- Synchronisiert: Neue erstellen, Updates anwenden, Deletes handhaben.
- Verwendet Locking, um parallele Syncs zu verhindern.
- **Trigger**: `queue('calendar_sync')`
#### API-Step (calendar_sync_api_step.py)
- Manueller Trigger für einzelnen Employee oder "ALL".
- Bei "ALL": Emittiert "calendar_sync_all".
- Bei Employee: Setzt Lock und emittiert "calendar_sync".
- **Trigger**: `http('POST', '/advoware/calendar/sync')`
## API-Schwächen und Fixes
### Advoware API
- **Mehrtägige Termine**: `datumBis` wird korrekt für Enddatum verwendet; '00:00:00' als '23:59:59' interpretiert.
- **Zeitformate**: Robuste Parsing mit Fallbacks.
- **Keine 24h-Limit**: Termine können länger als 24h sein; Google Calendar unterstützt das.
### Google Calendar API
- **Zeitbereiche**: Akzeptiert Events >24h ohne Probleme.
- **Rate Limits**: Backoff-Retry implementiert.
## Step-Konfiguration (Motia III)
### calendar_sync_cron_step.py
```python
config = {
'name': 'Calendar Sync Cron Job',
'flows': ['advoware'],
'triggers': [
cron("0 */15 * * * *") # Alle 15 Minuten (6-field format)
],
'enqueues': ['calendar_sync_all']
}
```
### calendar_sync_all_step.py
```python
config = {
'name': 'Calendar Sync All Step',
'flows': ['advoware'],
'triggers': [
queue('calendar_sync_all')
],
'enqueues': ['calendar_sync']
}
```
### calendar_sync_event_step.py
```python
config = {
'name': 'Calendar Sync Event Step',
'flows': ['advoware'],
'triggers': [
queue('calendar_sync')
],
'enqueues': []
}
```
### calendar_sync_api_step.py
```python
config = {
'name': 'Calendar Sync API Trigger',
'flows': ['advoware'],
'triggers': [
http('POST', '/advoware/calendar/sync')
],
'enqueues': ['calendar_sync', 'calendar_sync_all']
}
```
## Setup
### Umgebungsvariablen
```env
# Google Calendar
GOOGLE_CALENDAR_SERVICE_ACCOUNT_PATH=service-account.json
# Advoware API
ADVOWARE_API_BASE_URL=https://www2.advo-net.net:90/
ADVOWARE_PRODUCT_ID=64
ADVOWARE_APP_ID=your_app_id
ADVOWARE_API_KEY=your_api_key
ADVOWARE_KANZLEI=your_kanzlei
ADVOWARE_DATABASE=your_database
ADVOWARE_USER=your_user
ADVOWARE_ROLE=2
ADVOWARE_PASSWORD=your_password
ADVOWARE_TOKEN_LIFETIME_MINUTES=55
ADVOWARE_API_TIMEOUT_SECONDS=30
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB_CALENDAR_SYNC=1
REDIS_TIMEOUT_SECONDS=5
# Debug
CALENDAR_SYNC_DEBUG_EMPLOYEES=PB,AI # Optional, filter employees
```
## Verwendung
### Manueller Sync
```bash
# Sync für einen bestimmten Mitarbeiter
curl -X POST "http://localhost:3111/advoware/calendar/sync" \
-H "Content-Type: application/json" \
-d '{"kuerzel": "PB"}'
# Sync für alle Mitarbeiter
curl -X POST "http://localhost:3111/advoware/calendar/sync" \
-H "Content-Type: application/json" \
-d '{"kuerzel": "ALL"}'
```
### Automatischer Sync
Cron-Step läuft automatisch alle 15 Minuten.
## Fehlerbehandlung und Logging
- **Locking**: Redis NX/EX verhindert parallele Syncs.
- **Logging**: `ctx.logger` für iii Console-Sichtbarkeit.
- **API-Fehler**: Retry mit Backoff.
- **Parsing-Fehler**: Robuste Fallbacks.
## Sicherheit
- Service Account für Google Calendar API.
- HMAC-512 Authentifizierung für Advoware API.
- Redis für Concurrency-Control.
## Bekannte Probleme
- **Recurring-Events**: Begrenzte Unterstützung für komplexe Wiederholungen.
- **Performance**: Bei vielen Terminen Paginierung beachten.
- **Timezone-Handling**: Alle Operationen in Europe/Berlin TZ.
## Datenfluss
```
Cron (alle 15min)
→ calendar_sync_cron_step
→ ctx.enqueue(topic: "calendar_sync_all")
→ calendar_sync_all_step
→ Fetch Employees from Advoware
→ For each Employee:
→ Set Redis Lock (key: calendar_sync:employee:{kuerzel})
→ ctx.enqueue(topic: "calendar_sync", data: {kuerzel, ...})
→ calendar_sync_event_step
→ Fetch Advoware Termine (frNr, datum, text, etc.)
→ Fetch Google Calendar Events
→ 4-Phase Sync:
1. New from Advoware → Google
2. New from Google → Advoware
3. Process Deletes
4. Process Updates
→ Clear Redis Lock
```
## Weitere Dokumentation
- **Individual Step Docs**: Siehe `docs/` Ordner in diesem Verzeichnis
- **Architecture Overview**: [../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md)
- **Google Setup Guide**: [../../docs/GOOGLE_SETUP.md](../../docs/GOOGLE_SETUP.md)
- **Troubleshooting**: [../../docs/TROUBLESHOOTING.md](../../docs/TROUBLESHOOTING.md)
## Migration Notes
Dieses System wurde von **Motia v0.17** nach **Motia III v1.0-RC** migriert:
### Wichtige Änderungen:
-`type: 'event'``triggers: [queue('topic')]`
-`type: 'cron'``triggers: [cron('expression')]` (6-field format)
-`type: 'api'``triggers: [http('METHOD', 'path')]`
-`context.emit()``ctx.enqueue()`
-`emits: [...]``enqueues: [...]`
- ✅ Relative Imports → Absolute Imports mit `sys.path.insert()`
- ✅ Motia Workbench → iii Console
### Kompatibilität:
- ✅ Alle 4 Steps vollständig migriert
- ✅ Google Calendar API Integration unverändert
- ✅ Advoware API Integration unverändert
- ✅ Redis Locking-Mechanismus unverändert
- ✅ Datenbank-Schema kompatibel

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, Dict
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-calendar-sync'],
'triggers': [
queue('calendar_sync_all')
],
'enqueues': ['calendar_sync_employee']
}
async def handler(input_data: Dict[str, Any], ctx: FlowContext) -> None:
"""
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, get_logger
from motia import http, ApiRequest, ApiResponse, FlowContext
config = {
'name': 'Calendar Sync API Trigger',
'description': 'API endpoint for manual calendar sync triggering',
'flows': ['advoware-calendar-sync'],
'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': 'Please provide kuerzel in body'
}
)
kuerzel_upper = kuerzel.upper()
if kuerzel_upper == 'ALL':
# Emit sync-all event
ctx.logger.info("Calendar Sync API: Emitting sync-all event")
await ctx.enqueue({
"topic": "calendar_sync_all",
"data": {
"triggered_by": "api"
}
})
return ApiResponse(
status=200,
body={
'status': 'triggered',
'message': 'Calendar sync triggered for all employees',
'triggered_by': 'api'
}
)
else:
# Single employee sync
redis_client = get_redis_client(ctx)
if not set_employee_lock(redis_client, kuerzel_upper, 'api', ctx):
ctx.logger.info(f"Calendar Sync API: Sync already active for {kuerzel_upper}, skipping")
return ApiResponse(
status=409,
body={
'status': 'conflict',
'message': f'Calendar sync already active for {kuerzel_upper}',
'kuerzel': kuerzel_upper,
'triggered_by': 'api'
}
)
ctx.logger.info(f"Calendar Sync API called for {kuerzel_upper}")
# 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 triggered for {kuerzel_upper}',
'kuerzel': kuerzel_upper,
'triggered_by': 'api'
}
)
except Exception as e:
ctx.logger.error(f"Error in API trigger: {e}")
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 typing import Dict, Any
from motia import cron, FlowContext
config = {
'name': 'Calendar Sync Cron Job',
'description': 'Runs calendar sync automatically every 15 minutes',
'flows': ['advoware-calendar-sync'],
'triggers': [
cron("0 15 1 * * *") # Every 15 minutes at second 0 (6-field: sec min hour day month weekday)
],
'enqueues': ['calendar_sync_all']
}
async def handler(input_data: None, ctx: FlowContext) -> None:
"""Cron handler that triggers the calendar sync cascade."""
try:
ctx.logger.info("=" * 80)
ctx.logger.info("🕐 CALENDAR SYNC CRON: STARTING")
ctx.logger.info("=" * 80)
ctx.logger.info("Emitting sync-all event")
# Enqueue sync-all event
await ctx.enqueue({
"topic": "calendar_sync_all",
"data": {
"triggered_by": "cron"
}
})
ctx.logger.info("✅ Calendar sync-all event emitted successfully")
ctx.logger.info("=" * 80)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: CALENDAR SYNC CRON")
ctx.logger.error(f"Error: {e}")
ctx.logger.error("=" * 80)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
"""
Calendar Sync Utilities
Shared utility functions for calendar synchronization between Google Calendar and Advoware.
"""
import asyncpg
import os
import redis
import time
from typing import Optional, Any, List
from googleapiclient.discovery import build
from google.oauth2 import service_account
from services.logging_utils import get_service_logger
def get_logger(context=None):
"""Get logger for calendar sync operations"""
return get_service_logger('calendar_sync', context)
def log_operation(level: str, message: str, context=None, **extra):
"""
Log calendar sync operations with structured context.
Args:
level: Log level ('debug', 'info', 'warning', 'error')
message: Log message
context: FlowContext if available
**extra: Additional key-value pairs to log
"""
logger = get_logger(context)
log_func = getattr(logger, level.lower(), logger.info)
if extra:
extra_str = " | " + " | ".join(f"{k}={v}" for k, v in extra.items())
log_func(message + extra_str)
else:
log_func(message)
async def connect_db(context=None):
"""Connect to Postgres DB from environment variables."""
logger = get_logger(context)
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:
logger.error(f"Failed to connect to DB: {e}")
raise
async def get_google_service(context=None):
"""Initialize Google Calendar service."""
logger = get_logger(context)
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:
logger.error(f"Failed to initialize Google service: {e}")
raise
def get_redis_client(context=None) -> redis.Redis:
"""Initialize Redis client for calendar sync operations."""
logger = get_logger(context)
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')),
decode_responses=True
)
return redis_client
except Exception as e:
logger.error(f"Failed to initialize Redis client: {e}")
raise
async def get_advoware_employees(advoware, context=None) -> List[Any]:
"""Fetch list of employees from Advoware."""
logger = get_logger(context)
try:
result = await advoware.api_call('api/v1/advonet/Mitarbeiter', method='GET', params={'aktiv': 'true'})
employees = result if isinstance(result, list) else []
logger.info(f"Fetched {len(employees)} Advoware employees")
return employees
except Exception as e:
logger.error(f"Failed to fetch Advoware employees: {e}")
raise
def set_employee_lock(redis_client: redis.Redis, kuerzel: str, triggered_by: str, context=None) -> bool:
"""Set lock for employee sync operation."""
logger = get_logger(context)
employee_lock_key = f'calendar_sync_lock_{kuerzel}'
if redis_client.set(employee_lock_key, triggered_by, ex=1800, nx=True) is None:
logger.info(f"Sync already active for {kuerzel}, skipping")
return False
return True
def clear_employee_lock(redis_client: redis.Redis, kuerzel: str, context=None) -> None:
"""Clear lock for employee sync operation and update last-synced timestamp."""
logger = get_logger(context)
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)
logger.debug(f"Cleared lock and updated last-synced for {kuerzel} to {current_time}")
except Exception as e:
logger.warning(f"Failed to clear lock and update last-synced for {kuerzel}: {e}")

View File

@@ -0,0 +1,314 @@
# Advoware API Proxy Steps
Dieser Ordner enthält die API-Proxy-Steps für die Advoware-Integration. Jeder Step implementiert eine HTTP-Methode als universellen Proxy zur Advoware-API mit **Motia III v1.0-RC**.
## Übersicht
Die Proxy-Steps fungieren als transparente Schnittstelle zwischen Clients und der Advoware-API. Sie handhaben Authentifizierung, Fehlerbehandlung und Logging automatisch.
## Steps
### 1. GET Proxy (`advoware_api_proxy_get_step.py`)
**Zweck:** Universeller Proxy für GET-Requests an die Advoware-API.
**Konfiguration:**
```python
config = {
'name': 'Advoware Proxy GET',
'flows': ['advoware'],
'triggers': [
http('GET', '/advoware/proxy')
],
'enqueues': []
}
```
**Funktionalität:**
- Extrahiert den Ziel-Endpoint aus Query-Parametern (`endpoint`)
- Übergibt alle anderen Query-Parameter als API-Parameter
- Gibt das Ergebnis als JSON zurück
**Beispiel Request:**
```bash
GET /advoware/proxy?endpoint=employees&limit=10&offset=0
```
**Response:**
```json
{
"result": {
"data": [...],
"total": 100
}
}
```
### 2. POST Proxy (`advoware_api_proxy_post_step.py`)
**Zweck:** Universeller Proxy für POST-Requests an die Advoware-API.
**Konfiguration:**
```python
config = {
'name': 'Advoware Proxy POST',
'flows': ['advoware'],
'triggers': [
http('POST', '/advoware/proxy')
],
'enqueues': []
}
```
**Funktionalität:**
- Extrahiert den Ziel-Endpoint aus Query-Parametern (`endpoint`)
- Verwendet den Request-Body als JSON-Daten für die API
- Erstellt neue Ressourcen in Advoware
**Beispiel Request:**
```bash
POST /advoware/proxy?endpoint=employees
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com"
}
```
### 3. PUT Proxy (`advoware_api_proxy_put_step.py`)
**Zweck:** Universeller Proxy für PUT-Requests an die Advoware-API.
**Konfiguration:**
```python
config = {
'name': 'Advoware Proxy PUT',
'flows': ['advoware'],
'triggers': [
http('PUT', '/advoware/proxy')
],
'enqueues': []
}
```
**Funktionalität:**
- Extrahiert den Ziel-Endpoint aus Query-Parametern (`endpoint`)
- Verwendet den Request-Body als JSON-Daten für Updates
- Aktualisiert bestehende Ressourcen in Advoware
**Beispiel Request:**
```bash
PUT /advoware/proxy?endpoint=employees/123
Content-Type: application/json
{
"name": "John Smith",
"email": "johnsmith@example.com"
}
```
### 4. DELETE Proxy (`advoware_api_proxy_delete_step.py`)
**Zweck:** Universeller Proxy für DELETE-Requests an die Advoware-API.
**Konfiguration:**
```python
config = {
'name': 'Advoware Proxy DELETE',
'flows': ['advoware'],
'triggers': [
http('DELETE', '/advoware/proxy')
],
'enqueues': []
}
```
**Funktionalität:**
- Extrahiert den Ziel-Endpoint aus Query-Parametern (`endpoint`)
- Löscht Ressourcen in Advoware
**Beispiel Request:**
```bash
DELETE /advoware/proxy?endpoint=employees/123
```
## Gemeinsame Features
### Authentifizierung
Alle Steps verwenden den `AdvowareService` für automatische Token-Verwaltung und Authentifizierung:
- HMAC-512 basierte Signatur
- Token-Caching in Redis (55 Minuten Lifetime)
- Automatischer Token-Refresh bei 401-Errors
### Fehlerbehandling
- **400 Bad Request:** Fehlender `endpoint` Parameter
- **500 Internal Server Error:** API-Fehler oder Exceptions
- **401 Unauthorized:** Automatischer Token-Refresh und Retry
### Logging
Detaillierte Logs via `ctx.logger` für:
- Eingehende Requests
- API-Calls an Advoware
- Fehler und Exceptions
- Token-Management
Alle Logs sind in der **iii Console** sichtbar.
### Sicherheit
- Keine direkte Weitergabe sensibler Daten
- Authentifizierung über Service-Layer
- Input-Validation für erforderliche Parameter
- HMAC-512 Signatur für alle API-Requests
## Handler-Struktur (Motia III)
Alle Steps folgen dem gleichen Pattern:
```python
from motia import http, ApiRequest, ApiResponse, FlowContext
from services.advoware_service import AdvowareService
config = {
'name': 'Advoware Proxy {METHOD}',
'flows': ['advoware'],
'triggers': [
http('{METHOD}', '/advoware/proxy')
],
'enqueues': []
}
async def handler(request: ApiRequest, ctx: FlowContext) -> ApiResponse:
# Extract endpoint from query params
endpoint = request.query_params.get('endpoint')
if not endpoint:
return ApiResponse(
status=400,
body={'error': 'Missing required query parameter: endpoint'}
)
# Call Advoware API
advoware_service = AdvowareService()
result = await advoware_service.{method}(endpoint, **params)
return ApiResponse(status=200, body={'result': result})
```
## Testing
### Unit Tests
```bash
# Test GET Proxy
curl -X GET "http://localhost:3111/advoware/proxy?endpoint=employees"
# Test POST Proxy
curl -X POST "http://localhost:3111/advoware/proxy?endpoint=employees" \
-H "Content-Type: application/json" \
-d '{"name": "Test Employee"}'
# Test PUT Proxy
curl -X PUT "http://localhost:3111/advoware/proxy?endpoint=employees/1" \
-H "Content-Type: application/json" \
-d '{"name": "Updated Employee"}'
# Test DELETE Proxy
curl -X DELETE "http://localhost:3111/advoware/proxy?endpoint=employees/1"
```
### Integration Tests
Überprüfen Sie die Logs in der iii Console:
```bash
# Check logs
curl http://localhost:3111/_console/logs
```
## Konfiguration
### Umgebungsvariablen
Stellen Sie sicher, dass folgende Variablen gesetzt sind:
```env
ADVOWARE_API_BASE_URL=https://www2.advo-net.net:90/
ADVOWARE_PRODUCT_ID=64
ADVOWARE_APP_ID=your_app_id
ADVOWARE_API_KEY=your_api_key
ADVOWARE_KANZLEI=your_kanzlei
ADVOWARE_DATABASE=your_database
ADVOWARE_USER=your_user
ADVOWARE_ROLE=2
ADVOWARE_PASSWORD=your_password
ADVOWARE_TOKEN_LIFETIME_MINUTES=55
ADVOWARE_API_TIMEOUT_SECONDS=30
# Redis (für Token-Caching)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
```
### Dependencies
- `services/advoware_service.py` - Advoware API Client mit HMAC Auth
- `config.py` - Konfigurationsmanagement
- `motia` - Motia III Python SDK
- `asyncpg` - PostgreSQL Client
- `redis` - Redis Client für Token-Caching
## Erweiterungen
### Geplante Features
- Request/Response Caching für häufige Queries
- Rate Limiting pro Client
- Request Validation Schemas mit Pydantic
- Batch-Operations Support
### Custom Endpoints
Für spezifische Endpoints können zusätzliche Steps erstellt werden, die direkt auf bestimmte Ressourcen zugreifen und erweiterte Validierung/Transformation bieten.
## Architektur
```
Client Request
HTTP Trigger (http('METHOD', '/advoware/proxy'))
Handler (ApiRequest → ApiResponse)
├─► Extract 'endpoint' from query params
├─► Extract other params/body
AdvowareService
├─► Check Redis for valid token
├─► If expired: Get new token (HMAC-512 auth)
├─► Build HTTP request
Advoware API
Response → Transform → Return ApiResponse
```
## Migration Notes
Dieses System wurde von **Motia v0.17** nach **Motia III v1.0-RC** migriert:
### Wichtige Änderungen:
-`type: 'api'``triggers: [http('METHOD', 'path')]`
-`ApiRouteConfig``StepConfig` mit `as const satisfies`
-`Handlers['StepName']``Handlers<typeof config>`
-`context``ctx`
-`req` dict → `ApiRequest` typed object
- ✅ Return dict → `ApiResponse` typed object
-`method`, `path` moved into trigger
- ✅ Motia Workbench → iii Console
### Kompatibilität:
- ✅ Alle 4 Proxy Steps vollständig migriert
- ✅ AdvowareService kompatibel (keine Änderungen)
- ✅ Redis Token-Caching unverändert
- ✅ HMAC-512 Auth unverändert
- ✅ API-Endpoints identisch

View File

@@ -0,0 +1 @@
"""Advoware Proxy Steps"""

View File

@@ -0,0 +1,65 @@
"""Advoware API Proxy - DELETE requests"""
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
from services.advoware import AdvowareAPI
config = {
"name": "Advoware Proxy DELETE",
"description": "Universal proxy for Advoware API (DELETE requests)",
"flows": ["advoware-proxy"],
"triggers": [
http("DELETE", "/advoware/proxy")
],
"enqueues": [],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Proxy DELETE requests to Advoware API.
Query parameters:
- endpoint: Advoware API endpoint (required)
- any other params are forwarded to Advoware
"""
try:
# Extract endpoint from query parameters
endpoint = request.query_params.get('endpoint', '')
if not endpoint:
return ApiResponse(
status=400,
body={'error': 'Endpoint required as query parameter'}
)
ctx.logger.info("=" * 80)
ctx.logger.info("🔄 ADVOWARE PROXY: DELETE REQUEST")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Endpoint: {endpoint}")
ctx.logger.info("=" * 80)
# Initialize Advoware client
advoware = AdvowareAPI(ctx)
# Forward all query params except 'endpoint'
params = {k: v for k, v in request.query_params.items() if k != 'endpoint'}
result = await advoware.api_call(
endpoint,
method='DELETE',
params=params
)
ctx.logger.info("✅ Proxy DELETE erfolgreich")
return ApiResponse(status=200, body={'result': result})
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ADVOWARE PROXY DELETE FEHLER")
ctx.logger.error(f"Endpoint: {request.query_params.get('endpoint', 'N/A')}")
ctx.logger.error(f"Error: {e}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={'error': 'Internal server error', 'details': str(e)}
)

View File

@@ -0,0 +1,65 @@
"""Advoware API Proxy - GET requests"""
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
from services.advoware import AdvowareAPI
config = {
"name": "Advoware Proxy GET",
"description": "Universal proxy for Advoware API (GET requests)",
"flows": ["advoware-proxy"],
"triggers": [
http("GET", "/advoware/proxy")
],
"enqueues": [],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Proxy GET requests to Advoware API.
Query parameters:
- endpoint: Advoware API endpoint (required)
- any other params are forwarded to Advoware
"""
try:
# Extract endpoint from query parameters
endpoint = request.query_params.get('endpoint', '')
if not endpoint:
return ApiResponse(
status=400,
body={'error': 'Endpoint required as query parameter'}
)
ctx.logger.info("=" * 80)
ctx.logger.info("🔄 ADVOWARE PROXY: GET REQUEST")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Endpoint: {endpoint}")
ctx.logger.info("=" * 80)
# Initialize Advoware client
advoware = AdvowareAPI(ctx)
# Forward all query params except 'endpoint'
params = {k: v for k, v in request.query_params.items() if k != 'endpoint'}
result = await advoware.api_call(
endpoint,
method='GET',
params=params
)
ctx.logger.info("✅ Proxy GET erfolgreich")
return ApiResponse(status=200, body={'result': result})
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ADVOWARE PROXY GET FEHLER")
ctx.logger.error(f"Endpoint: {request.query_params.get('endpoint', 'N/A')}")
ctx.logger.error(f"Error: {e}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={'error': 'Internal server error', 'details': str(e)}
)

View File

@@ -0,0 +1,71 @@
"""Advoware API Proxy - POST requests"""
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
from services.advoware import AdvowareAPI
config = {
"name": "Advoware Proxy POST",
"description": "Universal proxy for Advoware API (POST requests)",
"flows": ["advoware-proxy"],
"triggers": [
http("POST", "/advoware/proxy")
],
"enqueues": [],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Proxy POST requests to Advoware API.
Query parameters:
- endpoint: Advoware API endpoint (required)
- any other params are forwarded to Advoware
Body: JSON payload to forward to Advoware
"""
try:
# Extract endpoint from query parameters
endpoint = request.query_params.get('endpoint', '')
if not endpoint:
return ApiResponse(
status=400,
body={'error': 'Endpoint required as query parameter'}
)
ctx.logger.info("=" * 80)
ctx.logger.info("🔄 ADVOWARE PROXY: POST REQUEST")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Endpoint: {endpoint}")
ctx.logger.info("=" * 80)
# Initialize Advoware client
advoware = AdvowareAPI(ctx)
# Forward all query params except 'endpoint'
params = {k: v for k, v in request.query_params.items() if k != 'endpoint'}
# Get request body
json_data = request.body
result = await advoware.api_call(
endpoint,
method='POST',
params=params,
json_data=json_data
)
ctx.logger.info("✅ Proxy POST erfolgreich")
return ApiResponse(status=200, body={'result': result})
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ADVOWARE PROXY POST FEHLER")
ctx.logger.error(f"Endpoint: {request.query_params.get('endpoint', 'N/A')}")
ctx.logger.error(f"Error: {e}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={'error': 'Internal server error', 'details': str(e)}
)

View File

@@ -0,0 +1,71 @@
"""Advoware API Proxy - PUT requests"""
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
from services.advoware import AdvowareAPI
config = {
"name": "Advoware Proxy PUT",
"description": "Universal proxy for Advoware API (PUT requests)",
"flows": ["advoware-proxy"],
"triggers": [
http("PUT", "/advoware/proxy")
],
"enqueues": [],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Proxy PUT requests to Advoware API.
Query parameters:
- endpoint: Advoware API endpoint (required)
- any other params are forwarded to Advoware
Body: JSON payload to forward to Advoware
"""
try:
# Extract endpoint from query parameters
endpoint = request.query_params.get('endpoint', '')
if not endpoint:
return ApiResponse(
status=400,
body={'error': 'Endpoint required as query parameter'}
)
ctx.logger.info("=" * 80)
ctx.logger.info("🔄 ADVOWARE PROXY: PUT REQUEST")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Endpoint: {endpoint}")
ctx.logger.info("=" * 80)
# Initialize Advoware client
advoware = AdvowareAPI(ctx)
# Forward all query params except 'endpoint'
params = {k: v for k, v in request.query_params.items() if k != 'endpoint'}
# Get request body
json_data = request.body
result = await advoware.api_call(
endpoint,
method='PUT',
params=params,
json_data=json_data
)
ctx.logger.info("✅ Proxy PUT erfolgreich")
return ApiResponse(status=200, body={'result': result})
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ADVOWARE PROXY PUT FEHLER")
ctx.logger.error(f"Endpoint: {request.query_params.get('endpoint', 'N/A')}")
ctx.logger.error(f"Error: {e}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={'error': 'Internal server error', 'details': str(e)}
)

View File

@@ -0,0 +1 @@
"""VMH Steps"""

View File

@@ -0,0 +1,90 @@
"""AI Knowledge Daily Sync - Cron Job"""
from typing import Any
from motia import FlowContext, cron
config = {
"name": "AI Knowledge Daily Sync",
"description": "Daily sync of all CAIKnowledge entities (catches missed webhooks, Blake3 verification included)",
"flows": ["aiknowledge-full-sync"],
"triggers": [
cron("0 0 2 * * *"), # Daily at 2:00 AM
],
"enqueues": ["aiknowledge.sync"],
}
async def handler(input_data: None, ctx: FlowContext[Any]) -> None:
"""
Daily sync handler - ensures all active knowledge bases are synchronized.
Loads all CAIKnowledge entities that need sync and emits events.
Blake3 hash verification is always performed (hash available from JunctionData API).
Runs every day at 02:00:00.
"""
from services.espocrm import EspoCRMAPI
from services.models import AIKnowledgeActivationStatus, AIKnowledgeSyncStatus
ctx.logger.info("=" * 80)
ctx.logger.info("🌙 DAILY AI KNOWLEDGE SYNC STARTED")
ctx.logger.info("=" * 80)
espocrm = EspoCRMAPI(ctx)
try:
# Load all CAIKnowledge entities with status 'active' that need sync
result = await espocrm.list_entities(
'CAIKnowledge',
where=[
{
'type': 'equals',
'attribute': 'aktivierungsstatus',
'value': AIKnowledgeActivationStatus.ACTIVE.value
},
{
'type': 'in',
'attribute': 'syncStatus',
'value': [
AIKnowledgeSyncStatus.UNCLEAN.value,
AIKnowledgeSyncStatus.FAILED.value
]
}
],
select='id,name,syncStatus',
max_size=1000 # Adjust if you have more
)
entities = result.get('list', [])
total = len(entities)
ctx.logger.info(f"📊 Found {total} knowledge bases needing sync")
if total == 0:
ctx.logger.info("✅ All knowledge bases are synced")
ctx.logger.info("=" * 80)
return
# Enqueue sync events for all (Blake3 verification always enabled)
for i, entity in enumerate(entities, 1):
await ctx.enqueue({
'topic': 'aiknowledge.sync',
'data': {
'knowledge_id': entity['id'],
'source': 'daily_cron'
}
})
ctx.logger.info(
f"📤 [{i}/{total}] Enqueued: {entity['name']} "
f"(syncStatus={entity.get('syncStatus')})"
)
ctx.logger.info("=" * 80)
ctx.logger.info(f"✅ Daily sync complete: {total} events enqueued")
ctx.logger.info("=" * 80)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ FULL SYNC FAILED")
ctx.logger.error("=" * 80)
ctx.logger.error(f"Error: {e}", exc_info=True)
raise

View File

@@ -0,0 +1,89 @@
"""AI Knowledge Sync Event Handler"""
from typing import Dict, Any
from redis import Redis
from motia import FlowContext, queue
config = {
"name": "AI Knowledge Sync",
"description": "Synchronizes CAIKnowledge entities with XAI Collections",
"flows": ["vmh-aiknowledge"],
"triggers": [
queue("aiknowledge.sync")
],
}
async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]) -> None:
"""
Event handler for AI Knowledge synchronization.
Emitted by:
- Webhook on CAIKnowledge update
- Daily full sync cron job
Args:
event_data: Event payload with knowledge_id
ctx: Motia context
"""
from services.redis_client import RedisClientFactory
from services.aiknowledge_sync_utils import AIKnowledgeSync
ctx.logger.info("=" * 80)
ctx.logger.info("🔄 AI KNOWLEDGE SYNC STARTED")
ctx.logger.info("=" * 80)
# Extract data
knowledge_id = event_data.get('knowledge_id')
source = event_data.get('source', 'unknown')
if not knowledge_id:
ctx.logger.error("❌ Missing knowledge_id in event data")
return
ctx.logger.info(f"📋 Knowledge ID: {knowledge_id}")
ctx.logger.info(f"📋 Source: {source}")
ctx.logger.info("=" * 80)
# Get Redis for locking
redis_client = RedisClientFactory.get_client(strict=False)
# Initialize sync utils
sync_utils = AIKnowledgeSync(ctx, redis_client)
# Acquire lock
lock_acquired = await sync_utils.acquire_sync_lock(knowledge_id)
if not lock_acquired:
ctx.logger.warn(f"⏸️ Lock already held for {knowledge_id}, skipping")
ctx.logger.info(" (Will be retried by Motia queue)")
raise RuntimeError(f"Lock busy for {knowledge_id}") # Motia will retry
try:
# Perform sync (Blake3 hash verification always enabled)
await sync_utils.sync_knowledge_to_xai(knowledge_id, ctx)
ctx.logger.info("=" * 80)
ctx.logger.info("✅ AI KNOWLEDGE SYNC COMPLETED")
ctx.logger.info("=" * 80)
# Release lock with success=True
await sync_utils.release_sync_lock(knowledge_id, success=True)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ AI KNOWLEDGE SYNC FAILED")
ctx.logger.error("=" * 80)
ctx.logger.error(f"Error: {e}")
ctx.logger.error(f"Knowledge ID: {knowledge_id}")
ctx.logger.error("=" * 80)
# Release lock with failure
await sync_utils.release_sync_lock(
knowledge_id,
success=False,
error_message=str(e)
)
# Re-raise to let Motia retry
raise

View File

@@ -0,0 +1,254 @@
"""
VMH Bankverbindungen Sync Handler
Zentraler Sync-Handler für Bankverbindungen (Webhooks + Cron Events)
Verarbeitet:
- vmh.bankverbindungen.create: Neu in EspoCRM → Create in Advoware
- vmh.bankverbindungen.update: Geändert in EspoCRM → Notification (nicht unterstützt)
- vmh.bankverbindungen.delete: Gelöscht in EspoCRM → Notification (nicht unterstützt)
- vmh.bankverbindungen.sync_check: Cron-Check → Sync wenn nötig
"""
from typing import Dict, Any, Optional
from motia import FlowContext, queue
from services.advoware import AdvowareAPI
from services.espocrm import EspoCRMAPI
from services.bankverbindungen_mapper import BankverbindungenMapper
from services.notification_utils import NotificationManager
from services.redis_client import get_redis_client
import json
config = {
"name": "VMH Bankverbindungen Sync Handler",
"description": "Zentraler Sync-Handler für Bankverbindungen (Webhooks + Cron Events)",
"flows": ["vmh-bankverbindungen"],
"triggers": [
queue("vmh.bankverbindungen.create"),
queue("vmh.bankverbindungen.update"),
queue("vmh.bankverbindungen.delete"),
queue("vmh.bankverbindungen.sync_check")
],
"enqueues": []
}
async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]) -> None:
"""Zentraler Sync-Handler für Bankverbindungen"""
entity_id = event_data.get('entity_id')
action = event_data.get('action', 'sync_check')
source = event_data.get('source', 'unknown')
if not entity_id:
ctx.logger.error("Keine entity_id im Event gefunden")
return
ctx.logger.info(f"🔄 Bankverbindungen Sync gestartet: {action.upper()} | Entity: {entity_id} | Source: {source}")
# Shared Redis client (centralized factory)
redis_client = get_redis_client(strict=False)
# APIs initialisieren (mit Context für besseres Logging)
espocrm = EspoCRMAPI(ctx)
advoware = AdvowareAPI(ctx)
mapper = BankverbindungenMapper()
notification_mgr = NotificationManager(espocrm_api=espocrm, context=ctx)
try:
# 1. ACQUIRE LOCK
lock_key = f"sync_lock:cbankverbindungen:{entity_id}"
acquired = redis_client.set(lock_key, "locked", nx=True, ex=900) # 15min TTL
if not acquired:
ctx.logger.warn(f"⏸️ Sync bereits aktiv für {entity_id}, überspringe")
return
# 2. FETCH ENTITY VON ESPOCRM
try:
espo_entity = await espocrm.get_entity('CBankverbindungen', entity_id)
except Exception as e:
ctx.logger.error(f"❌ Fehler beim Laden von EspoCRM Entity: {e}")
redis_client.delete(lock_key)
return
ctx.logger.info(f"📋 Entity geladen: {espo_entity.get('name', 'Unbenannt')} (IBAN: {espo_entity.get('iban', 'N/A')})")
advoware_id = espo_entity.get('advowareId')
beteiligte_id = espo_entity.get('cBeteiligteId') # Parent Beteiligter
if not beteiligte_id:
ctx.logger.error(f"❌ Keine cBeteiligteId gefunden - Bankverbindung muss einem Beteiligten zugeordnet sein")
redis_client.delete(lock_key)
return
# Hole betNr vom Parent
parent = await espocrm.get_entity('CBeteiligte', beteiligte_id)
betnr = parent.get('betnr')
if not betnr:
ctx.logger.error(f"❌ Parent Beteiligter {beteiligte_id} hat keine betNr")
redis_client.delete(lock_key)
return
# 3. BESTIMME SYNC-AKTION
# FALL A: Neu (kein advowareId) → CREATE in Advoware
if not advoware_id and action in ['create', 'sync_check']:
await handle_create(entity_id, betnr, espo_entity, espocrm, advoware, mapper, ctx, redis_client, lock_key)
# FALL B: Existiert (hat advowareId) → UPDATE oder CHECK (nicht unterstützt!)
elif advoware_id and action in ['update', 'sync_check']:
await handle_update(entity_id, betnr, advoware_id, espo_entity, espocrm, notification_mgr, ctx, redis_client, lock_key)
# FALL C: DELETE (nicht unterstützt!)
elif action == 'delete':
await handle_delete(entity_id, betnr, advoware_id, espo_entity, espocrm, notification_mgr, ctx, redis_client, lock_key)
else:
ctx.logger.warn(f"⚠️ Unbekannte Kombination: action={action}, advowareId={advoware_id}")
redis_client.delete(lock_key)
except Exception as e:
ctx.logger.error(f"❌ Unerwarteter Fehler im Sync-Handler: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
try:
redis_client.delete(lock_key)
except:
pass
async def handle_create(entity_id, betnr, espo_entity, espocrm, advoware, mapper, ctx, redis_client, lock_key) -> None:
"""Erstellt neue Bankverbindung in Advoware"""
try:
ctx.logger.info(f"🔨 CREATE Bankverbindung in Advoware für Beteiligter {betnr}...")
advo_data = mapper.map_cbankverbindungen_to_advoware(espo_entity)
ctx.logger.info(f"📤 Sende an Advoware: {json.dumps(advo_data, ensure_ascii=False)[:200]}...")
# POST zu Advoware (Beteiligten-spezifischer Endpoint!)
result = await advoware.api_call(
f'api/v1/advonet/Beteiligte/{betnr}/Bankverbindungen',
method='POST',
json_data=advo_data
)
# Extrahiere ID und rowId
if isinstance(result, list) and len(result) > 0:
new_entity = result[0]
elif isinstance(result, dict):
new_entity = result
else:
raise Exception(f"Unexpected response format: {result}")
new_id = new_entity.get('id')
new_rowid = new_entity.get('rowId')
if not new_id:
raise Exception(f"Keine ID in Advoware Response: {result}")
ctx.logger.info(f"✅ In Advoware erstellt: ID={new_id}, rowId={new_rowid[:20] if new_rowid else 'N/A'}...")
# Schreibe advowareId + rowId zurück
await espocrm.update_entity('CBankverbindungen', entity_id, {
'advowareId': new_id,
'advowareRowId': new_rowid
})
redis_client.delete(lock_key)
ctx.logger.info(f"✅ CREATE erfolgreich: {entity_id} → Advoware ID {new_id}")
except Exception as e:
ctx.logger.error(f"❌ CREATE fehlgeschlagen: {e}")
redis_client.delete(lock_key)
async def handle_update(entity_id, betnr, advoware_id, espo_entity, espocrm, notification_mgr, ctx, redis_client, lock_key) -> None:
"""Update nicht möglich - Sendet Notification an User"""
try:
ctx.logger.warn(f"⚠️ UPDATE: Advoware API unterstützt kein PUT für Bankverbindungen")
iban = espo_entity.get('iban', 'N/A')
bank = espo_entity.get('bank', 'N/A')
name = espo_entity.get('name', 'Unbenannt')
# Sende Notification
await notification_mgr.notify_manual_action_required(
entity_type='CBankverbindungen',
entity_id=entity_id,
action_type='general_manual_action',
details={
'message': f'UPDATE nicht möglich für Bankverbindung: {name}',
'description': (
f"Die Advoware API unterstützt keine Updates für Bankverbindungen.\n\n"
f"**Details:**\n"
f"- Bank: {bank}\n"
f"- IBAN: {iban}\n"
f"- Beteiligter betNr: {betnr}\n"
f"- Advoware ID: {advoware_id}\n\n"
f"**Workaround:**\n"
f"Löschen Sie die Bankverbindung in EspoCRM und erstellen Sie sie neu. "
f"Die neue Bankverbindung wird dann automatisch in Advoware angelegt."
),
'entity_name': name,
'priority': 'Normal'
},
create_task=True
)
ctx.logger.info(f"📧 Notification gesendet: Update-Limitation")
redis_client.delete(lock_key)
except Exception as e:
ctx.logger.error(f"❌ UPDATE Notification fehlgeschlagen: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
redis_client.delete(lock_key)
async def handle_delete(entity_id, betnr, advoware_id, espo_entity, espocrm, notification_mgr, ctx, redis_client, lock_key) -> None:
"""Delete nicht möglich - Sendet Notification an User"""
try:
ctx.logger.warn(f"⚠️ DELETE: Advoware API unterstützt kein DELETE für Bankverbindungen")
if not advoware_id:
ctx.logger.info(f" Keine advowareId vorhanden, nur EspoCRM-seitiges Delete")
redis_client.delete(lock_key)
return
iban = espo_entity.get('iban', 'N/A')
bank = espo_entity.get('bank', 'N/A')
name = espo_entity.get('name', 'Unbenannt')
# Sende Notification
await notification_mgr.notify_manual_action_required(
entity_type='CBankverbindungen',
entity_id=entity_id,
action_type='general_manual_action',
details={
'message': f'DELETE erforderlich für Bankverbindung: {name}',
'description': (
f"Die Advoware API unterstützt keine Löschungen für Bankverbindungen.\n\n"
f"**Bitte manuell in Advoware löschen:**\n"
f"- Bank: {bank}\n"
f"- IBAN: {iban}\n"
f"- Beteiligter betNr: {betnr}\n"
f"- Advoware ID: {advoware_id}\n\n"
f"Die Bankverbindung wurde in EspoCRM gelöscht, bleibt aber in Advoware "
f"bestehen bis zur manuellen Löschung."
),
'entity_name': name,
'priority': 'Normal'
},
create_task=True
)
ctx.logger.info(f"📧 Notification gesendet: Delete erforderlich")
redis_client.delete(lock_key)
except Exception as e:
ctx.logger.error(f"❌ DELETE Notification fehlgeschlagen: {e}")
redis_client.delete(lock_key)

View File

@@ -0,0 +1,164 @@
"""
Beteiligte Sync Cron Job
Läuft alle 15 Minuten und emittiert Sync-Events für Beteiligte die:
- Neu sind (pending_sync)
- Geändert wurden (dirty)
- Fehlgeschlagen sind (failed → Retry)
- Lange nicht gesynct wurden (clean aber > 24h alt)
"""
import asyncio
from typing import Dict, Any
from motia import FlowContext, cron
from services.espocrm import EspoCRMAPI
import datetime
config = {
"name": "VMH Beteiligte Sync Cron",
"description": "Prüft alle 15 Minuten welche Beteiligte synchronisiert werden müssen",
"flows": ["vmh-beteiligte"],
"triggers": [
cron("0 */15 1 * * *") # Alle 15 Minuten (6-field format!)
],
"enqueues": ["vmh.beteiligte.sync_check"]
}
async def handler(input_data: Dict[str, Any], ctx: FlowContext) -> None:
"""
Cron-Handler: Findet alle Beteiligte die Sync benötigen und emittiert Events
"""
ctx.logger.info("🕐 Beteiligte Sync Cron gestartet")
try:
espocrm = EspoCRMAPI(ctx)
# Berechne Threshold für "veraltete" Syncs (24 Stunden)
threshold = datetime.datetime.now() - datetime.timedelta(hours=24)
threshold_str = threshold.strftime('%Y-%m-%d %H:%M:%S')
ctx.logger.info(f"📅 Suche Entities mit Sync-Bedarf (älter als {threshold_str})")
# QUERY 1: Entities mit Status pending_sync, dirty oder failed
unclean_filter = {
'where': [
{
'type': 'or',
'value': [
{'type': 'equals', 'attribute': 'syncStatus', 'value': 'pending_sync'},
{'type': 'equals', 'attribute': 'syncStatus', 'value': 'dirty'},
{'type': 'equals', 'attribute': 'syncStatus', 'value': 'failed'},
]
}
]
}
unclean_result = await espocrm.list_entities('CBeteiligte', where=unclean_filter['where'], max_size=100)
unclean_entities = unclean_result.get('list', [])
ctx.logger.info(f"📊 Gefunden: {len(unclean_entities)} Entities mit Status pending/dirty/failed")
# QUERY 1b: permanently_failed Entities die Auto-Reset erreicht haben
permanently_failed_filter = {
'where': [
{
'type': 'and',
'value': [
{'type': 'equals', 'attribute': 'syncStatus', 'value': 'permanently_failed'},
{'type': 'isNotNull', 'attribute': 'syncAutoResetAt'},
{'type': 'before', 'attribute': 'syncAutoResetAt', 'value': threshold_str}
]
}
]
}
reset_result = await espocrm.list_entities('CBeteiligte', where=permanently_failed_filter['where'], max_size=50)
reset_entities = reset_result.get('list', [])
# Reset permanently_failed entities
for entity in reset_entities:
entity_id = entity['id']
ctx.logger.info(f"🔄 Auto-Reset für permanently_failed Entity {entity_id}")
# Reset Status und Retry-Count
await espocrm.update_entity('CBeteiligte', entity_id, {
'syncStatus': 'failed', # Zurück zu 'failed' für normalen Retry
'syncRetryCount': 0,
'syncAutoResetAt': None,
'syncErrorMessage': f"Auto-Reset nach 24h - vorheriger Fehler: {entity.get('syncErrorMessage', 'N/A')}"
})
ctx.logger.info(f"📊 Auto-Reset: {len(reset_entities)} permanently_failed Entities")
# QUERY 2: Clean Entities die > 24h nicht gesynct wurden
stale_filter = {
'where': [
{
'type': 'and',
'value': [
{'type': 'equals', 'attribute': 'syncStatus', 'value': 'clean'},
{'type': 'isNotNull', 'attribute': 'betnr'},
{
'type': 'or',
'value': [
{'type': 'isNull', 'attribute': 'advowareLastSync'},
{'type': 'before', 'attribute': 'advowareLastSync', 'value': threshold_str}
]
}
]
}
]
}
stale_result = await espocrm.list_entities('CBeteiligte', where=stale_filter['where'], max_size=50)
stale_entities = stale_result.get('list', [])
ctx.logger.info(f"📊 Gefunden: {len(stale_entities)} Entities mit veraltetem Sync (> 24h)")
# KOMBINIERE ALLE (inkl. reset_entities)
all_entities = unclean_entities + stale_entities + reset_entities
entity_ids = list(set([e['id'] for e in all_entities])) # Dedupliziere
ctx.logger.info(f"🎯 Total: {len(entity_ids)} eindeutige Entities zum Sync")
if not entity_ids:
ctx.logger.info("✅ Keine Entities benötigen Sync")
return
# Emittiere Events parallel
ctx.logger.info(f"🚀 Emittiere {len(entity_ids)} Events parallel...")
emit_tasks = [
ctx.enqueue({
'topic': 'vmh.beteiligte.sync_check',
'data': {
'entity_id': entity_id,
'action': 'sync_check',
'source': 'cron',
'timestamp': datetime.datetime.now().isoformat()
}
})
for entity_id in entity_ids
]
# Parallel emit mit error handling
results = await asyncio.gather(*emit_tasks, return_exceptions=True)
# Count successes and failures
emitted_count = sum(1 for r in results if not isinstance(r, Exception))
failed_count = sum(1 for r in results if isinstance(r, Exception))
if failed_count > 0:
ctx.logger.warn(f"⚠️ {failed_count} Events konnten nicht emittiert werden")
# Log first few errors
for i, result in enumerate(results[:5]): # Log max 5 errors
if isinstance(result, Exception):
ctx.logger.error(f" Entity {entity_ids[i]}: {result}")
ctx.logger.info(f"✅ Cron fertig: {emitted_count}/{len(entity_ids)} Events emittiert")
except Exception as e:
ctx.logger.error(f"❌ Fehler im Sync Cron: {e}")
import traceback
ctx.logger.error(traceback.format_exc())

View File

@@ -0,0 +1,423 @@
"""
VMH Beteiligte Sync Handler
Zentraler Sync-Handler für Beteiligte (Webhooks + Cron Events)
Verarbeitet:
- vmh.beteiligte.create: Neu in EspoCRM → Create in Advoware
- vmh.beteiligte.update: Geändert in EspoCRM → Update in Advoware
- vmh.beteiligte.delete: Gelöscht in EspoCRM → Delete in Advoware (TODO)
- vmh.beteiligte.sync_check: Cron-Check → Sync wenn nötig
"""
from typing import Dict, Any, Optional
from motia import FlowContext, queue
from services.advoware import AdvowareAPI
from services.advoware_service import AdvowareService
from services.espocrm import EspoCRMAPI
from services.espocrm_mapper import BeteiligteMapper
from services.beteiligte_sync_utils import BeteiligteSync
from services.redis_client import get_redis_client
from services.exceptions import (
AdvowareAPIError,
EspoCRMAPIError,
SyncError,
RetryableError,
is_retryable
)
from services.logging_utils import get_step_logger
import json
config = {
"name": "VMH Beteiligte Sync Handler",
"description": "Zentraler Sync-Handler für Beteiligte (Webhooks + Cron Events)",
"flows": ["vmh-beteiligte"],
"triggers": [
queue("vmh.beteiligte.create"),
queue("vmh.beteiligte.update"),
queue("vmh.beteiligte.delete"),
queue("vmh.beteiligte.sync_check")
],
"enqueues": []
}
async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]) -> None:
"""
Zentraler Sync-Handler für Beteiligte
Args:
event_data: Event data mit entity_id, action, source
ctx: Motia FlowContext
"""
entity_id = event_data.get('entity_id')
action = event_data.get('action')
source = event_data.get('source')
step_logger = get_step_logger('beteiligte_sync', ctx)
if not entity_id:
step_logger.error("Keine entity_id im Event gefunden")
return
step_logger.info("=" * 80)
step_logger.info(f"🔄 BETEILIGTE SYNC HANDLER: {action.upper()}")
step_logger.info("=" * 80)
step_logger.info(f"Entity: {entity_id} | Source: {source}")
step_logger.info("=" * 80)
# Get shared Redis client (centralized)
redis_client = get_redis_client(strict=False)
# APIs initialisieren
espocrm = EspoCRMAPI(ctx)
advoware = AdvowareAPI(ctx)
sync_utils = BeteiligteSync(espocrm, redis_client, ctx)
mapper = BeteiligteMapper()
# NOTE: Kommunikation Sync Manager wird in zukünftiger Version hinzugefügt
# wenn kommunikation_sync_utils.py migriert ist
# advo_service = AdvowareService(ctx)
# komm_sync = KommunikationSyncManager(advo_service, espocrm, ctx)
try:
# 1. ACQUIRE LOCK (verhindert parallele Syncs)
lock_acquired = await sync_utils.acquire_sync_lock(entity_id)
if not lock_acquired:
ctx.logger.warn(f"⏸️ Sync bereits aktiv für {entity_id}, überspringe")
return
# Lock erfolgreich acquired - MUSS im finally block released werden!
try:
# 2. FETCH ENTITY VON ESPOCRM
try:
espo_entity = await espocrm.get_entity('CBeteiligte', entity_id)
except Exception as e:
ctx.logger.error(f"❌ Fehler beim Laden von EspoCRM Entity: {e}")
await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True)
return
ctx.logger.info(f"📋 Entity geladen: {espo_entity.get('name')} (betnr: {espo_entity.get('betnr')})")
betnr = espo_entity.get('betnr')
sync_status = espo_entity.get('syncStatus', 'pending_sync')
# Check Retry-Backoff - überspringe wenn syncNextRetry noch nicht erreicht
sync_next_retry = espo_entity.get('syncNextRetry')
if sync_next_retry and sync_status == 'failed':
import datetime
import pytz
try:
next_retry_ts = datetime.datetime.strptime(sync_next_retry, '%Y-%m-%d %H:%M:%S')
next_retry_ts = pytz.UTC.localize(next_retry_ts)
now_utc = datetime.datetime.now(pytz.UTC)
if now_utc < next_retry_ts:
remaining_minutes = int((next_retry_ts - now_utc).total_seconds() / 60)
ctx.logger.info(f"⏸️ Retry-Backoff aktiv: Nächster Versuch in {remaining_minutes} Minuten")
await sync_utils.release_sync_lock(entity_id, sync_status)
return
except Exception as e:
ctx.logger.warn(f"⚠️ Fehler beim Parsen von syncNextRetry: {e}")
# 3. BESTIMME SYNC-AKTION
# FALL A: Neu (kein betnr) → CREATE in Advoware
if not betnr and action in ['create', 'sync_check']:
ctx.logger.info(f"🆕 Neuer Beteiligter → CREATE in Advoware")
await handle_create(entity_id, espo_entity, espocrm, advoware, sync_utils, mapper, ctx)
# FALL B: Existiert (hat betnr) → UPDATE oder CHECK
elif betnr:
ctx.logger.info(f"♻️ Existierender Beteiligter (betNr: {betnr}) → UPDATE/CHECK")
await handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_utils, mapper, ctx)
# FALL C: DELETE (TODO: Implementierung später)
elif action == 'delete':
ctx.logger.warn(f"🗑️ DELETE noch nicht implementiert für {entity_id}")
await sync_utils.release_sync_lock(entity_id, 'failed', 'Delete-Operation nicht implementiert')
else:
ctx.logger.warn(f"⚠️ Unbekannte Kombination: action={action}, betnr={betnr}")
await sync_utils.release_sync_lock(entity_id, 'failed', f'Unbekannte Aktion: {action}')
except Exception as e:
# Unerwarteter Fehler während Sync - GARANTIERE Lock-Release
ctx.logger.error(f"❌ Unerwarteter Fehler im Sync-Handler: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
try:
await sync_utils.release_sync_lock(
entity_id,
'failed',
f'Unerwarteter Fehler: {str(e)[:1900]}',
increment_retry=True
)
except Exception as release_error:
# Selbst Lock-Release failed - logge kritischen Fehler
ctx.logger.critical(f"🚨 CRITICAL: Lock-Release failed für {entity_id}: {release_error}")
# Force Redis lock release
try:
lock_key = f"sync_lock:cbeteiligte:{entity_id}"
redis_client.delete(lock_key)
ctx.logger.info(f"✅ Redis lock manuell released: {lock_key}")
except:
pass
except Exception as e:
# Fehler VOR Lock-Acquire - kein Lock-Release nötig
ctx.logger.error(f"❌ Fehler vor Lock-Acquire: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
async def handle_create(entity_id, espo_entity, espocrm, advoware, sync_utils, mapper, ctx) -> None:
"""Erstellt neuen Beteiligten in Advoware"""
try:
ctx.logger.info(f"🔨 CREATE in Advoware...")
# Transform zu Advoware Format
advo_data = mapper.map_cbeteiligte_to_advoware(espo_entity)
ctx.logger.info(f"📤 Sende an Advoware: {json.dumps(advo_data, ensure_ascii=False)[:200]}...")
# POST zu Advoware
result = await advoware.api_call(
'api/v1/advonet/Beteiligte',
method='POST',
json_data=advo_data
)
# Extrahiere betNr aus Response (case-insensitive: betNr oder betnr)
new_betnr = None
if isinstance(result, dict):
new_betnr = result.get('betNr') or result.get('betnr')
if not new_betnr:
raise Exception(f"Keine betNr/betnr in Advoware Response: {result}")
ctx.logger.info(f"✅ In Advoware erstellt: betNr={new_betnr}")
# Lade Entity nach POST um rowId zu bekommen (WICHTIG für Change Detection!)
created_entity = await advoware.api_call(
f'api/v1/advonet/Beteiligte/{new_betnr}',
method='GET'
)
if isinstance(created_entity, list):
new_rowid = created_entity[0].get('rowId') if created_entity else None
else:
new_rowid = created_entity.get('rowId')
if not new_rowid:
ctx.logger.warn(f"⚠️ Keine rowId nach CREATE - Change Detection nicht möglich!")
# OPTIMIERT: Kombiniere release_lock + betnr + rowId update in 1 API call
await sync_utils.release_sync_lock(
entity_id,
'clean',
error_message=None,
extra_fields={
'betnr': new_betnr,
'advowareRowId': new_rowid # WICHTIG für Change Detection!
}
)
ctx.logger.info(f"✅ CREATE erfolgreich: {entity_id} → betNr {new_betnr}, rowId {new_rowid[:20] if new_rowid else 'N/A'}...")
except Exception as e:
ctx.logger.error(f"❌ CREATE fehlgeschlagen: {e}")
await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True)
async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_utils, mapper, ctx) -> None:
"""Synchronisiert existierenden Beteiligten"""
try:
ctx.logger.info(f"🔍 Fetch von Advoware betNr={betnr}...")
# Fetch von Advoware
try:
advo_result = await advoware.api_call(
f'api/v1/advonet/Beteiligte/{betnr}',
method='GET'
)
# Advoware gibt manchmal Listen zurück
if isinstance(advo_result, list):
advo_entity = advo_result[0] if advo_result else None
else:
advo_entity = advo_result
if not advo_entity:
raise Exception(f"Beteiligter betNr={betnr} nicht gefunden")
except Exception as e:
# 404 oder anderer Fehler → Beteiligter wurde in Advoware gelöscht
if '404' in str(e) or 'nicht gefunden' in str(e).lower():
ctx.logger.warn(f"🗑️ Beteiligter in Advoware gelöscht: betNr={betnr}")
await sync_utils.handle_advoware_deleted(entity_id, str(e))
return
else:
raise
ctx.logger.info(f"📥 Von Advoware geladen: {advo_entity.get('name')}")
# ÄNDERUNGSERKENNUNG (Primary: rowId, Fallback: Timestamps)
comparison = sync_utils.compare_entities(espo_entity, advo_entity)
ctx.logger.info(f"⏱️ Vergleich: {comparison}")
# KEIN STAMMDATEN-SYNC NÖTIG
if comparison == 'no_change':
ctx.logger.info(f"✅ Keine Stammdaten-Änderungen erkannt")
# NOTE: Kommunikation-Sync würde hier stattfinden
# await run_kommunikation_sync(entity_id, betnr, komm_sync, ctx)
await sync_utils.release_sync_lock(entity_id, 'clean')
return
# ESPOCRM NEUER → Update Advoware
if comparison == 'espocrm_newer':
ctx.logger.info(f"📤 EspoCRM ist neuer → Update Advoware STAMMDATEN")
# OPTIMIERT: Use merge utility
merged_data = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
put_result = await advoware.api_call(
f'api/v1/advonet/Beteiligte/{betnr}',
method='PUT',
json_data=merged_data
)
# Extrahiere neue rowId aus PUT Response (spart extra GET!)
new_rowid = None
if isinstance(put_result, list) and len(put_result) > 0:
new_rowid = put_result[0].get('rowId')
elif isinstance(put_result, dict):
new_rowid = put_result.get('rowId')
ctx.logger.info(f"✅ Advoware STAMMDATEN aktualisiert, rowId: {new_rowid[:20] if new_rowid else 'N/A'}...")
# Validiere Sync-Ergebnis
validation_success, validation_error = await sync_utils.validate_sync_result(
entity_id, betnr, mapper, direction='to_advoware'
)
if not validation_success:
ctx.logger.error(f"❌ Sync-Validation fehlgeschlagen: {validation_error}")
await sync_utils.release_sync_lock(
entity_id,
'failed',
error_message=f"Validation failed: {validation_error}",
increment_retry=True
)
return
# NOTE: Kommunikation-Sync würde hier stattfinden
# await run_kommunikation_sync(entity_id, betnr, komm_sync, ctx)
# Release Lock + Update rowId
await sync_utils.release_sync_lock(
entity_id,
'clean',
extra_fields={'advowareRowId': new_rowid}
)
# ADVOWARE NEUER → Update EspoCRM
elif comparison == 'advoware_newer':
ctx.logger.info(f"📥 Advoware ist neuer → Update EspoCRM STAMMDATEN")
espo_data = mapper.map_advoware_to_cbeteiligte(advo_entity)
await espocrm.update_entity('CBeteiligte', entity_id, espo_data)
ctx.logger.info(f"✅ EspoCRM STAMMDATEN aktualisiert")
# Validiere Sync-Ergebnis
validation_success, validation_error = await sync_utils.validate_sync_result(
entity_id, betnr, mapper, direction='to_espocrm'
)
if not validation_success:
ctx.logger.error(f"❌ Sync-Validation fehlgeschlagen: {validation_error}")
await sync_utils.release_sync_lock(
entity_id,
'failed',
error_message=f"Validation failed: {validation_error}",
increment_retry=True
)
return
# NOTE: Kommunikation-Sync würde hier stattfinden
# await run_kommunikation_sync(entity_id, betnr, komm_sync, ctx)
# Release Lock + Update rowId
await sync_utils.release_sync_lock(
entity_id,
'clean',
extra_fields={'advowareRowId': advo_entity.get('rowId')}
)
# KONFLIKT → EspoCRM WINS
elif comparison == 'conflict':
ctx.logger.warn(f"⚠️ KONFLIKT erkannt → EspoCRM WINS (STAMMDATEN)")
# OPTIMIERT: Use merge utility
merged_data = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
put_result = await advoware.api_call(
f'api/v1/advonet/Beteiligte/{betnr}',
method='PUT',
json_data=merged_data
)
# Extrahiere neue rowId aus PUT Response
new_rowid = None
if isinstance(put_result, list) and len(put_result) > 0:
new_rowid = put_result[0].get('rowId')
elif isinstance(put_result, dict):
new_rowid = put_result.get('rowId')
conflict_msg = (
f"EspoCRM: {espo_entity.get('modifiedAt')}, "
f"Advoware: {advo_entity.get('geaendertAm')}. "
f"EspoCRM hat gewonnen."
)
ctx.logger.info(f"✅ Konflikt gelöst (EspoCRM won), neue rowId: {new_rowid[:20] if new_rowid else 'N/A'}...")
# Validiere Sync-Ergebnis
validation_success, validation_error = await sync_utils.validate_sync_result(
entity_id, betnr, mapper, direction='to_advoware'
)
if not validation_success:
ctx.logger.error(f"❌ Conflict resolution validation fehlgeschlagen: {validation_error}")
await sync_utils.release_sync_lock(
entity_id,
'failed',
error_message=f"Conflict resolution validation failed: {validation_error}",
increment_retry=True
)
return
await sync_utils.resolve_conflict_espocrm_wins(
entity_id,
espo_entity,
advo_entity,
conflict_msg,
extra_fields={'advowareRowId': new_rowid}
)
# NOTE: Kommunikation-Sync (nur EspoCRM→Advoware) würde hier stattfinden
# await run_kommunikation_sync(entity_id, betnr, komm_sync, ctx, direction='to_advoware', force_espo_wins=True)
await sync_utils.release_sync_lock(entity_id, 'clean')
except Exception as e:
ctx.logger.error(f"❌ UPDATE fehlgeschlagen: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True)

View File

@@ -0,0 +1,394 @@
"""
VMH Document Sync Handler
Zentraler Sync-Handler für Documents mit xAI Collections
Verarbeitet:
- vmh.document.create: Neu in EspoCRM → Prüfe ob xAI-Sync nötig
- vmh.document.update: Geändert in EspoCRM → Prüfe ob xAI-Sync/Update nötig
- vmh.document.delete: Gelöscht in EspoCRM → Remove from xAI Collections
"""
from typing import Dict, Any
from motia import FlowContext, queue
from services.espocrm import EspoCRMAPI
from services.document_sync_utils import DocumentSync
from services.xai_service import XAIService
from services.redis_client import get_redis_client
import hashlib
import json
config = {
"name": "VMH Document Sync Handler",
"description": "Zentraler Sync-Handler für Documents mit xAI Collections",
"flows": ["vmh-documents"],
"triggers": [
queue("vmh.document.create"),
queue("vmh.document.update"),
queue("vmh.document.delete")
],
"enqueues": []
}
async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]) -> None:
"""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')
if not entity_id:
ctx.logger.error("Keine entity_id im Event gefunden")
return
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}")
ctx.logger.info("=" * 80)
# Shared Redis client for distributed locking (centralized factory)
redis_client = get_redis_client(strict=False)
# APIs initialisieren (mit Context für besseres Logging)
espocrm = EspoCRMAPI(ctx)
sync_utils = DocumentSync(espocrm, redis_client, ctx)
xai_service = XAIService(ctx)
try:
# 1. ACQUIRE LOCK (verhindert parallele Syncs)
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 {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(entity_type, entity_id)
except Exception as 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"📋 {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" 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, xai_service, ctx, entity_type)
elif action in ['create', 'update']:
await handle_create_or_update(entity_id, document, sync_utils, xai_service, 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}", entity_type=entity_type)
except Exception as e:
# Unerwarteter Fehler während Sync - GARANTIERE Lock-Release
ctx.logger.error(f"❌ Unerwarteter Fehler im Sync-Handler: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
try:
await sync_utils.release_sync_lock(
entity_id,
success=False,
error_message=str(e)[:2000],
entity_type=entity_type
)
except Exception as release_error:
# Selbst Lock-Release failed - logge kritischen Fehler
ctx.logger.critical(f"🚨 CRITICAL: Lock-Release failed für Document {entity_id}: {release_error}")
# Force Redis lock release
try:
lock_key = f"sync_lock:document:{entity_id}"
redis_client.delete(lock_key)
ctx.logger.info(f"✅ Redis lock manuell released: {lock_key}")
except:
pass
except Exception as e:
# Fehler VOR Lock-Acquire - kein Lock-Release nötig
ctx.logger.error(f"❌ Fehler vor Lock-Acquire: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
async def handle_create_or_update(entity_id: str, document: Dict[str, Any], sync_utils: DocumentSync, xai_service: XAIService, ctx: FlowContext[Any], entity_type: str = 'CDokumente') -> None:
"""
Behandelt Create/Update von Documents
Entscheidet ob xAI-Sync nötig ist und führt diesen durch
"""
try:
ctx.logger.info("")
ctx.logger.info("=" * 80)
ctx.logger.info("🔍 ANALYSE: Braucht dieses Document xAI-Sync?")
ctx.logger.info("=" * 80)
# 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" File-Status: {datei_status or 'N/A'}")
if collection_ids:
ctx.logger.info(f" Collections: {collection_ids}")
# ═══════════════════════════════════════════════════════════════
# CHECK: Knowledge Bases mit Status "new" (noch keine Collection)
# ═══════════════════════════════════════════════════════════════
new_knowledge_bases = [cid for cid in collection_ids if cid.startswith('NEW:')]
if new_knowledge_bases:
ctx.logger.info("")
ctx.logger.info("=" * 80)
ctx.logger.info("🆕 DOKUMENT IST MIT KNOWLEDGE BASE(S) VERKNÜPFT (Status: new)")
ctx.logger.info("=" * 80)
for new_kb in new_knowledge_bases:
kb_id = new_kb[4:] # Remove "NEW:" prefix
ctx.logger.info(f"📋 CAIKnowledge {kb_id}")
ctx.logger.info(f" Status: new → Collection muss zuerst erstellt werden")
# Trigger Knowledge Sync
ctx.logger.info(f"📤 Triggering aiknowledge.sync event...")
await ctx.emit('aiknowledge.sync', {
'entity_id': kb_id,
'entity_type': 'CAIKnowledge',
'triggered_by': 'document_sync',
'document_id': entity_id
})
ctx.logger.info(f"✅ Event emitted for {kb_id}")
# Release lock and skip document sync - knowledge sync will handle documents
ctx.logger.info("")
ctx.logger.info("=" * 80)
ctx.logger.info("✅ KNOWLEDGE SYNC GETRIGGERT")
ctx.logger.info(" Document Sync wird übersprungen")
ctx.logger.info(" (Knowledge Sync erstellt Collection und synchronisiert dann Dokumente)")
ctx.logger.info("=" * 80)
await sync_utils.release_sync_lock(entity_id, success=True, entity_type=entity_type)
return
# ═══════════════════════════════════════════════════════════════
# PREVIEW-GENERIERUNG bei neuen/geänderten Dateien
# ═══════════════════════════════════════════════════════════════
# 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")
ctx.logger.info(f" Datei-Status: {datei_status}")
ctx.logger.info("=" * 80)
try:
# 1. Hole Download-Informationen
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")
else:
ctx.logger.info(f"📥 Datei-Info:")
ctx.logger.info(f" Filename: {download_info['filename']}")
ctx.logger.info(f" MIME-Type: {download_info['mime_type']}")
ctx.logger.info(f" Size: {download_info['size']} bytes")
# 2. Download File von EspoCRM
ctx.logger.info(f"📥 Downloading file...")
espocrm = sync_utils.espocrm
file_content = await espocrm.download_attachment(download_info['attachment_id'])
ctx.logger.info(f"✅ Downloaded {len(file_content)} bytes")
# 3. Speichere temporär für Preview-Generierung
import tempfile
import os
with tempfile.NamedTemporaryFile(delete=False, suffix=f"_{download_info['filename']}") as tmp_file:
tmp_file.write(file_content)
tmp_path = tmp_file.name
try:
# 4. Generiere Preview
ctx.logger.info(f"🖼️ Generating preview (600x800 WebP)...")
preview_data = await sync_utils.generate_thumbnail(
tmp_path,
download_info['mime_type'],
max_width=600,
max_height=800
)
if preview_data:
ctx.logger.info(f"✅ Preview generated: {len(preview_data)} bytes WebP")
# 5. Upload Preview zu EspoCRM und reset file status
ctx.logger.info(f"📤 Uploading preview to EspoCRM...")
await sync_utils.update_sync_metadata(
entity_id,
preview_data=preview_data,
reset_file_status=True, # Reset status nach Preview-Generierung
entity_type=entity_type
)
ctx.logger.info(f"✅ Preview uploaded successfully")
else:
ctx.logger.warn("⚠️ Preview-Generierung lieferte keine Daten")
# Auch bei fehlgeschlagener Preview-Generierung Status zurücksetzen
await sync_utils.update_sync_metadata(
entity_id,
reset_file_status=True,
entity_type=entity_type
)
finally:
# Cleanup temp file
try:
os.remove(tmp_path)
except:
pass
except Exception as e:
ctx.logger.error(f"❌ Fehler bei Preview-Generierung: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
# Continue - Preview ist optional
ctx.logger.info("")
ctx.logger.info("=" * 80)
ctx.logger.info("✅ PREVIEW-VERARBEITUNG ABGESCHLOSSEN")
ctx.logger.info("=" * 80)
# ═══════════════════════════════════════════════════════════════
# xAI SYNC (falls erforderlich)
# ═══════════════════════════════════════════════════════════════
if not needs_sync:
ctx.logger.info("✅ Kein xAI-Sync erforderlich, Lock wird released")
# Wenn Preview generiert wurde aber kein xAI sync nötig,
# wurde Status bereits in Preview-Schritt zurückgesetzt
await sync_utils.release_sync_lock(entity_id, success=True, entity_type=entity_type)
return
# ═══════════════════════════════════════════════════════════════
# xAI SYNC DURCHFÜHREN
# ═══════════════════════════════════════════════════════════════
ctx.logger.info("")
ctx.logger.info("=" * 80)
ctx.logger.info("🤖 xAI SYNC STARTEN")
ctx.logger.info("=" * 80)
# 1. Hole Download-Informationen (falls nicht schon aus Preview-Schritt vorhanden)
download_info = await sync_utils.get_document_download_info(entity_id, entity_type)
if not download_info:
raise Exception("Konnte Download-Info nicht ermitteln Datei fehlt?")
ctx.logger.info(f"📥 Datei: {download_info['filename']} ({download_info['size']} bytes, {download_info['mime_type']})")
# 2. Download Datei von EspoCRM
espocrm = sync_utils.espocrm
file_content = await espocrm.download_attachment(download_info['attachment_id'])
ctx.logger.info(f"✅ Downloaded {len(file_content)} bytes")
# 3. MD5-Hash berechnen für Change-Detection
file_hash = hashlib.md5(file_content).hexdigest()
ctx.logger.info(f"🔑 MD5: {file_hash}")
# 4. Upload zu xAI
# Immer neu hochladen wenn needs_sync=True (neues File oder Hash geändert)
ctx.logger.info("📤 Uploading to xAI...")
xai_file_id = await xai_service.upload_file(
file_content,
download_info['filename'],
download_info['mime_type']
)
ctx.logger.info(f"✅ xAI file_id: {xai_file_id}")
# 5. Zu allen Ziel-Collections hinzufügen
ctx.logger.info(f"📚 Füge zu {len(collection_ids)} Collection(s) hinzu...")
added_collections = await xai_service.add_to_collections(collection_ids, xai_file_id)
ctx.logger.info(f"✅ In {len(added_collections)}/{len(collection_ids)} Collections eingetragen")
# 6. EspoCRM Metadaten aktualisieren und Lock freigeben
await sync_utils.update_sync_metadata(
entity_id,
xai_file_id=xai_file_id,
collection_ids=added_collections,
file_hash=file_hash,
entity_type=entity_type
)
await sync_utils.release_sync_lock(
entity_id,
success=True,
entity_type=entity_type
)
ctx.logger.info("=" * 80)
ctx.logger.info("✅ DOCUMENT SYNC ABGESCHLOSSEN")
ctx.logger.info("=" * 80)
except Exception as e:
ctx.logger.error(f"❌ Fehler bei Create/Update: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
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, xai_service: XAIService, ctx: FlowContext[Any], entity_type: str = 'CDokumente') -> None:
"""
Behandelt Delete von Documents
Entfernt Document aus xAI Collections (aber löscht File nicht - kann in anderen Collections sein)
"""
try:
ctx.logger.info("")
ctx.logger.info("=" * 80)
ctx.logger.info("🗑️ DOCUMENT DELETE - xAI CLEANUP")
ctx.logger.info("=" * 80)
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, entity_type=entity_type)
return
ctx.logger.info(f"📋 Document Info:")
ctx.logger.info(f" xaiFileId: {xai_file_id}")
ctx.logger.info(f" Collections: {xai_collections}")
ctx.logger.info(f"🗑️ Entferne aus {len(xai_collections)} Collection(s)...")
await xai_service.remove_from_collections(xai_collections, xai_file_id)
ctx.logger.info(f"✅ File aus {len(xai_collections)} Collection(s) entfernt")
ctx.logger.info(" (File selbst bleibt in xAI kann in anderen Collections sein)")
await sync_utils.release_sync_lock(entity_id, success=True, entity_type=entity_type)
ctx.logger.info("=" * 80)
ctx.logger.info("✅ DELETE ABGESCHLOSSEN")
ctx.logger.info("=" * 80)
except Exception as e:
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), entity_type=entity_type)

View File

@@ -0,0 +1 @@
"""VMH Webhook Steps"""

View File

@@ -0,0 +1,91 @@
"""VMH Webhook - AI Knowledge Update"""
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook AI Knowledge Update",
"description": "Receives update webhooks from EspoCRM for CAIKnowledge entities",
"flows": ["vmh-aiknowledge"],
"triggers": [
http("POST", "/vmh/webhook/aiknowledge/update")
],
"enqueues": ["aiknowledge.sync"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for CAIKnowledge updates in EspoCRM.
Triggered when:
- activationStatus changes
- syncStatus changes (e.g., set to 'unclean')
- Documents linked/unlinked
"""
try:
ctx.logger.info("=" * 80)
ctx.logger.info("🔔 AI Knowledge Update Webhook")
ctx.logger.info("=" * 80)
# Extract payload
payload = request.body
# Handle case where payload is a list (e.g., from array-based webhook)
if isinstance(payload, list):
if not payload:
ctx.logger.error("❌ Empty payload list")
return ApiResponse(
status=400,
body={'success': False, 'error': 'Empty payload'}
)
payload = payload[0] # Take first item
# Ensure payload is a dict
if not isinstance(payload, dict):
ctx.logger.error(f"❌ Invalid payload type: {type(payload)}")
return ApiResponse(
status=400,
body={'success': False, 'error': f'Invalid payload type: {type(payload).__name__}'}
)
# Validate required fields
knowledge_id = payload.get('entity_id') or payload.get('id')
entity_type = payload.get('entity_type', 'CAIKnowledge')
action = payload.get('action', 'update')
if not knowledge_id:
ctx.logger.error("❌ Missing entity_id in payload")
return ApiResponse(
status=400,
body={'success': False, 'error': 'Missing entity_id'}
)
ctx.logger.info(f"📋 Entity Type: {entity_type}")
ctx.logger.info(f"📋 Entity ID: {knowledge_id}")
ctx.logger.info(f"📋 Action: {action}")
# Enqueue sync event
await ctx.enqueue({
'topic': 'aiknowledge.sync',
'data': {
'knowledge_id': knowledge_id,
'source': 'webhook',
'action': action
}
})
ctx.logger.info(f"✅ Sync event enqueued for {knowledge_id}")
ctx.logger.info("=" * 80)
return ApiResponse(
status=200,
body={'success': True, 'knowledge_id': knowledge_id}
)
except Exception as e:
ctx.logger.error(f"❌ Webhook error: {e}")
return ApiResponse(
status=500,
body={'success': False, 'error': str(e)}
)

View File

@@ -0,0 +1,76 @@
"""VMH Webhook - Bankverbindungen Create"""
import json
import datetime
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook Bankverbindungen Create",
"description": "Receives create webhooks from EspoCRM for Bankverbindungen",
"flows": ["vmh-bankverbindungen"],
"triggers": [
http("POST", "/vmh/webhook/bankverbindungen/create")
],
"enqueues": ["vmh.bankverbindungen.create"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for Bankverbindungen creation in EspoCRM.
"""
try:
payload = request.body or []
ctx.logger.info("=" * 80)
ctx.logger.info("📥 VMH WEBHOOK: BANKVERBINDUNGEN CREATE")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
ctx.logger.info("=" * 80)
# Collect all IDs from batch
entity_ids = set()
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
ctx.logger.info(f"{len(entity_ids)} IDs found for create sync")
# Emit events
for entity_id in entity_ids:
await ctx.enqueue({
'topic': 'vmh.bankverbindungen.create',
'data': {
'entity_id': entity_id,
'action': 'create',
'source': 'webhook',
'timestamp': datetime.datetime.now().isoformat()
}
})
ctx.logger.info("✅ VMH Create Webhook processed: "
f"{len(entity_ids)} events emitted")
return ApiResponse(
status=200,
body={
'status': 'received',
'action': 'create',
'ids_count': len(entity_ids)
}
)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: BANKVERBINDUNGEN CREATE WEBHOOK")
ctx.logger.error(f"Error: {e}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={'error': 'Internal server error', 'details': str(e)}
)

View File

@@ -0,0 +1,76 @@
"""VMH Webhook - Bankverbindungen Delete"""
import json
import datetime
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook Bankverbindungen Delete",
"description": "Receives delete webhooks from EspoCRM for Bankverbindungen",
"flows": ["vmh-bankverbindungen"],
"triggers": [
http("POST", "/vmh/webhook/bankverbindungen/delete")
],
"enqueues": ["vmh.bankverbindungen.delete"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for Bankverbindungen deletion in EspoCRM.
"""
try:
payload = request.body or []
ctx.logger.info("=" * 80)
ctx.logger.info("📥 VMH WEBHOOK: BANKVERBINDUNGEN DELETE")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
ctx.logger.info("=" * 80)
# Collect all IDs
entity_ids = set()
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
ctx.logger.info(f"{len(entity_ids)} IDs found for delete sync")
# Emit events
for entity_id in entity_ids:
await ctx.enqueue({
'topic': 'vmh.bankverbindungen.delete',
'data': {
'entity_id': entity_id,
'action': 'delete',
'source': 'webhook',
'timestamp': datetime.datetime.now().isoformat()
}
})
ctx.logger.info("✅ VMH Delete Webhook processed: "
f"{len(entity_ids)} events emitted")
return ApiResponse(
status=200,
body={
'status': 'received',
'action': 'delete',
'ids_count': len(entity_ids)
}
)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: BANKVERBINDUNGEN DELETE WEBHOOK")
ctx.logger.error(f"Error: {e}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={'error': 'Internal server error', 'details': str(e)}
)

View File

@@ -0,0 +1,76 @@
"""VMH Webhook - Bankverbindungen Update"""
import json
import datetime
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook Bankverbindungen Update",
"description": "Receives update webhooks from EspoCRM for Bankverbindungen",
"flows": ["vmh-bankverbindungen"],
"triggers": [
http("POST", "/vmh/webhook/bankverbindungen/update")
],
"enqueues": ["vmh.bankverbindungen.update"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for Bankverbindungen updates in EspoCRM.
"""
try:
payload = request.body or []
ctx.logger.info("=" * 80)
ctx.logger.info("📥 VMH WEBHOOK: BANKVERBINDUNGEN UPDATE")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
ctx.logger.info("=" * 80)
# Collect all IDs
entity_ids = set()
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
ctx.logger.info(f"{len(entity_ids)} IDs found for update sync")
# Emit events
for entity_id in entity_ids:
await ctx.enqueue({
'topic': 'vmh.bankverbindungen.update',
'data': {
'entity_id': entity_id,
'action': 'update',
'source': 'webhook',
'timestamp': datetime.datetime.now().isoformat()
}
})
ctx.logger.info("✅ VMH Update Webhook processed: "
f"{len(entity_ids)} events emitted")
return ApiResponse(
status=200,
body={
'status': 'received',
'action': 'update',
'ids_count': len(entity_ids)
}
)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: BANKVERBINDUNGEN UPDATE WEBHOOK")
ctx.logger.error(f"Error: {e}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={'error': 'Internal server error', 'details': str(e)}
)

View File

@@ -0,0 +1,86 @@
"""VMH Webhook - Beteiligte Create"""
import json
import datetime
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook Beteiligte Create",
"description": "Receives create webhooks from EspoCRM for Beteiligte",
"flows": ["vmh-beteiligte"],
"triggers": [
http("POST", "/vmh/webhook/beteiligte/create")
],
"enqueues": ["vmh.beteiligte.create"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for Beteiligte creation in EspoCRM.
Receives batch or single entity notifications and emits queue events
for each entity ID to be synced to Advoware.
"""
try:
payload = request.body or []
ctx.logger.info("=" * 80)
ctx.logger.info("📥 VMH WEBHOOK: BETEILIGTE CREATE")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
ctx.logger.info("=" * 80)
# Collect all IDs from batch
entity_ids = set()
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
ctx.logger.info(f"{len(entity_ids)} IDs found for create sync")
# Emit events for queue processing (deduplication via lock in event handler)
for entity_id in entity_ids:
await ctx.enqueue({
'topic': 'vmh.beteiligte.create',
'data': {
'entity_id': entity_id,
'action': 'create',
'source': 'webhook',
'timestamp': datetime.datetime.now().isoformat()
}
})
ctx.logger.info("✅ VMH Create Webhook processed: "
f"{len(entity_ids)} events emitted")
return ApiResponse(
status=200,
body={
'status': 'received',
'action': 'create',
'ids_count': len(entity_ids)
}
)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: VMH CREATE WEBHOOK")
ctx.logger.error("=" * 80)
ctx.logger.error(f"Error: {e}")
ctx.logger.error(f"Entity IDs attempted: {list(entity_ids) if 'entity_ids' in locals() else 'N/A'}")
ctx.logger.error(f"Full Payload: {json.dumps(request.body, indent=2, ensure_ascii=False)}")
ctx.logger.error(f"Timestamp: {datetime.datetime.now().isoformat()}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={
'error': 'Internal server error',
'details': str(e)
}
)

View File

@@ -0,0 +1,76 @@
"""VMH Webhook - Beteiligte Delete"""
import json
import datetime
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook Beteiligte Delete",
"description": "Receives delete webhooks from EspoCRM for Beteiligte",
"flows": ["vmh-beteiligte"],
"triggers": [
http("POST", "/vmh/webhook/beteiligte/delete")
],
"enqueues": ["vmh.beteiligte.delete"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for Beteiligte deletion in EspoCRM.
"""
try:
payload = request.body or []
ctx.logger.info("=" * 80)
ctx.logger.info("📥 VMH WEBHOOK: BETEILIGTE DELETE")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
ctx.logger.info("=" * 80)
# Collect all IDs from batch
entity_ids = set()
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
ctx.logger.info(f"{len(entity_ids)} IDs found for delete sync")
# Emit events for queue processing
for entity_id in entity_ids:
await ctx.enqueue({
'topic': 'vmh.beteiligte.delete',
'data': {
'entity_id': entity_id,
'action': 'delete',
'source': 'webhook',
'timestamp': datetime.datetime.now().isoformat()
}
})
ctx.logger.info("✅ VMH Delete Webhook processed: "
f"{len(entity_ids)} events emitted")
return ApiResponse(
status=200,
body={
'status': 'received',
'action': 'delete',
'ids_count': len(entity_ids)
}
)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: BETEILIGTE DELETE WEBHOOK")
ctx.logger.error(f"Error: {e}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={'error': 'Internal server error', 'details': str(e)}
)

View File

@@ -0,0 +1,86 @@
"""VMH Webhook - Beteiligte Update"""
import json
import datetime
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook Beteiligte Update",
"description": "Receives update webhooks from EspoCRM for Beteiligte",
"flows": ["vmh-beteiligte"],
"triggers": [
http("POST", "/vmh/webhook/beteiligte/update")
],
"enqueues": ["vmh.beteiligte.update"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for Beteiligte updates in EspoCRM.
Note: Loop prevention is implemented on EspoCRM side.
rowId updates no longer trigger webhooks, so no filtering needed.
"""
try:
payload = request.body or []
ctx.logger.info("=" * 80)
ctx.logger.info("📥 VMH WEBHOOK: BETEILIGTE UPDATE")
ctx.logger.info("=" * 80)
ctx.logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
ctx.logger.info("=" * 80)
# Collect all IDs from batch
entity_ids = set()
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
ctx.logger.info(f"{len(entity_ids)} IDs found for update sync")
# Emit events for queue processing
for entity_id in entity_ids:
await ctx.enqueue({
'topic': 'vmh.beteiligte.update',
'data': {
'entity_id': entity_id,
'action': 'update',
'source': 'webhook',
'timestamp': datetime.datetime.now().isoformat()
}
})
ctx.logger.info("✅ VMH Update Webhook processed: "
f"{len(entity_ids)} events emitted")
return ApiResponse(
status=200,
body={
'status': 'received',
'action': 'update',
'ids_count': len(entity_ids)
}
)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: VMH UPDATE WEBHOOK")
ctx.logger.error("=" * 80)
ctx.logger.error(f"Error: {e}")
ctx.logger.error(f"Entity IDs attempted: {list(entity_ids) if 'entity_ids' in locals() else 'N/A'}")
ctx.logger.error(f"Full Payload: {json.dumps(request.body, indent=2, ensure_ascii=False)}")
ctx.logger.error(f"Timestamp: {datetime.datetime.now().isoformat()}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={
'error': 'Internal server error',
'details': str(e)
}
)

View File

@@ -0,0 +1,91 @@
"""VMH Webhook - Document Create"""
import json
import datetime
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook Document Create",
"description": "Empfängt Create-Webhooks von EspoCRM für Documents",
"flows": ["vmh-documents"],
"triggers": [
http("POST", "/vmh/webhook/document/create")
],
"enqueues": ["vmh.document.create"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for Document creation in EspoCRM.
Receives batch or single entity notifications and emits queue events
for each entity ID to be synced to xAI.
"""
try:
payload = request.body or []
ctx.logger.info("=" * 80)
ctx.logger.info("📥 VMH WEBHOOK: DOCUMENT CREATE")
ctx.logger.info("=" * 80)
ctx.logger.debug(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
# Collect all IDs from batch
entity_ids = set()
entity_type = 'CDokumente' # Default
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
# Take entityType from first entity if present
if entity_type == 'CDokumente':
entity_type = entity.get('entityType', 'CDokumente')
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
entity_type = payload.get('entityType', 'CDokumente')
ctx.logger.info(f"{len(entity_ids)} document IDs found for create sync")
# Emit events for queue processing (deduplication via lock in event handler)
for entity_id in entity_ids:
await ctx.enqueue({
'topic': 'vmh.document.create',
'data': {
'entity_id': entity_id,
'entity_type': entity_type,
'action': 'create',
'timestamp': payload[0].get('modifiedAt') if isinstance(payload, list) and payload else None
}
})
ctx.logger.info("✅ Document Create Webhook processed: "
f"{len(entity_ids)} events emitted")
return ApiResponse(
status=200,
body={
'success': True,
'message': f'{len(entity_ids)} document(s) enqueued for sync',
'entity_ids': list(entity_ids)
}
)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: DOCUMENT CREATE WEBHOOK")
ctx.logger.error("=" * 80)
ctx.logger.error(f"Error: {e}")
ctx.logger.error(f"Entity IDs attempted: {list(entity_ids) if 'entity_ids' in locals() else 'N/A'}")
ctx.logger.error(f"Full Payload: {json.dumps(request.body, indent=2, ensure_ascii=False)}")
ctx.logger.error(f"Timestamp: {datetime.datetime.now().isoformat()}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={
'success': False,
'error': str(e)
}
)

View File

@@ -0,0 +1,91 @@
"""VMH Webhook - Document Delete"""
import json
import datetime
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook Document Delete",
"description": "Empfängt Delete-Webhooks von EspoCRM für Documents",
"flows": ["vmh-documents"],
"triggers": [
http("POST", "/vmh/webhook/document/delete")
],
"enqueues": ["vmh.document.delete"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for Document deletion in EspoCRM.
Receives batch or single entity notifications and emits queue events
for each entity ID to be removed from xAI.
"""
try:
payload = request.body or []
ctx.logger.info("=" * 80)
ctx.logger.info("📥 VMH WEBHOOK: DOCUMENT DELETE")
ctx.logger.info("=" * 80)
ctx.logger.debug(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
# Collect all IDs from batch
entity_ids = set()
entity_type = 'CDokumente' # Default
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
# Take entityType from first entity if present
if entity_type == 'CDokumente':
entity_type = entity.get('entityType', 'CDokumente')
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
entity_type = payload.get('entityType', 'CDokumente')
ctx.logger.info(f"{len(entity_ids)} document IDs found for delete sync")
# Emit events for queue processing
for entity_id in entity_ids:
await ctx.enqueue({
'topic': 'vmh.document.delete',
'data': {
'entity_id': entity_id,
'entity_type': entity_type,
'action': 'delete',
'timestamp': payload[0].get('deletedAt') if isinstance(payload, list) and payload else None
}
})
ctx.logger.info("✅ Document Delete Webhook processed: "
f"{len(entity_ids)} events emitted")
return ApiResponse(
status=200,
body={
'success': True,
'message': f'{len(entity_ids)} document(s) enqueued for deletion',
'entity_ids': list(entity_ids)
}
)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: DOCUMENT DELETE WEBHOOK")
ctx.logger.error("=" * 80)
ctx.logger.error(f"Error: {e}")
ctx.logger.error(f"Entity IDs attempted: {list(entity_ids) if 'entity_ids' in locals() else 'N/A'}")
ctx.logger.error(f"Full Payload: {json.dumps(request.body, indent=2, ensure_ascii=False)}")
ctx.logger.error(f"Timestamp: {datetime.datetime.now().isoformat()}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={
'success': False,
'error': str(e)
}
)

View File

@@ -0,0 +1,91 @@
"""VMH Webhook - Document Update"""
import json
import datetime
from typing import Any
from motia import FlowContext, http, ApiRequest, ApiResponse
config = {
"name": "VMH Webhook Document Update",
"description": "Empfängt Update-Webhooks von EspoCRM für Documents",
"flows": ["vmh-documents"],
"triggers": [
http("POST", "/vmh/webhook/document/update")
],
"enqueues": ["vmh.document.update"],
}
async def handler(request: ApiRequest, ctx: FlowContext[Any]) -> ApiResponse:
"""
Webhook handler for Document updates in EspoCRM.
Receives batch or single entity notifications and emits queue events
for each entity ID to be synced to xAI.
"""
try:
payload = request.body or []
ctx.logger.info("=" * 80)
ctx.logger.info("📥 VMH WEBHOOK: DOCUMENT UPDATE")
ctx.logger.info("=" * 80)
ctx.logger.debug(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
# Collect all IDs from batch
entity_ids = set()
entity_type = 'CDokumente' # Default
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
# Take entityType from first entity if present
if entity_type == 'CDokumente':
entity_type = entity.get('entityType', 'CDokumente')
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
entity_type = payload.get('entityType', 'CDokumente')
ctx.logger.info(f"{len(entity_ids)} document IDs found for update sync")
# Emit events for queue processing
for entity_id in entity_ids:
await ctx.enqueue({
'topic': 'vmh.document.update',
'data': {
'entity_id': entity_id,
'entity_type': entity_type,
'action': 'update',
'timestamp': payload[0].get('modifiedAt') if isinstance(payload, list) and payload else None
}
})
ctx.logger.info("✅ Document Update Webhook processed: "
f"{len(entity_ids)} events emitted")
return ApiResponse(
status=200,
body={
'success': True,
'message': f'{len(entity_ids)} document(s) enqueued for sync',
'entity_ids': list(entity_ids)
}
)
except Exception as e:
ctx.logger.error("=" * 80)
ctx.logger.error("❌ ERROR: DOCUMENT UPDATE WEBHOOK")
ctx.logger.error("=" * 80)
ctx.logger.error(f"Error: {e}")
ctx.logger.error(f"Entity IDs attempted: {list(entity_ids) if 'entity_ids' in locals() else 'N/A'}")
ctx.logger.error(f"Full Payload: {json.dumps(request.body, indent=2, ensure_ascii=False)}")
ctx.logger.error(f"Timestamp: {datetime.datetime.now().isoformat()}")
ctx.logger.error("=" * 80)
return ApiResponse(
status=500,
body={
'success': False,
'error': str(e)
}
)

View File

@@ -1,61 +0,0 @@
"""Create Ticket Step - accepts a new support ticket via API and enqueues it for triage."""
import random
import string
from datetime import datetime, timezone
from typing import Any
from motia import ApiRequest, ApiResponse, FlowContext, http
config = {
"name": "CreateTicket",
"description": "Accepts a new support ticket via API and enqueues it for triage",
"flows": ["support-ticket-flow"],
"triggers": [
http("POST", "/tickets"),
],
"enqueues": ["ticket::created"],
}
async def handler(request: ApiRequest[dict[str, Any]], ctx: FlowContext[Any]) -> ApiResponse[Any]:
body = request.body or {}
title = body.get("title")
description = body.get("description")
priority = body.get("priority", "medium")
customer_email = body.get("customerEmail")
if not title or not description:
return ApiResponse(status=400, body={"error": "Title and description are required"})
random_suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=5))
ticket_id = f"TKT-{int(datetime.now(timezone.utc).timestamp() * 1000)}-{random_suffix}"
ticket = {
"id": ticket_id,
"title": title,
"description": description,
"priority": priority,
"customerEmail": customer_email,
"status": "open",
"createdAt": datetime.now(timezone.utc).isoformat(),
}
await ctx.state.set("tickets", ticket_id, ticket)
ctx.logger.info("Ticket created", {"ticketId": ticket_id, "priority": priority})
await ctx.enqueue({
"topic": "ticket::created",
"data": {
"ticketId": ticket_id,
"title": title,
"priority": priority,
"customerEmail": customer_email,
},
})
return ApiResponse(status=200, body={
"ticketId": ticket_id,
"status": "open",
"message": "Ticket created and queued for triage",
})

View File

@@ -1,90 +0,0 @@
"""Escalate Ticket Step - multi-trigger: escalates tickets from SLA breach or manual request.
Uses ctx.match() to route logic per trigger type.
"""
from datetime import datetime, timezone
from typing import Any
from motia import ApiRequest, ApiResponse, FlowContext, http, queue
config = {
"name": "EscalateTicket",
"description": "Multi-trigger: escalates tickets from SLA breach or manual request",
"flows": ["support-ticket-flow"],
"triggers": [
queue("ticket::sla-breached"),
http("POST", "/tickets/escalate"),
],
"enqueues": [],
}
async def _escalate_ticket(
ticket_id: str,
updates: dict[str, Any],
ctx: FlowContext[Any],
) -> dict[str, Any] | None:
"""Fetches a ticket and applies escalation fields to state. Returns pre-update ticket or None."""
existing = await ctx.state.get("tickets", ticket_id)
if not existing:
return None
await ctx.state.set("tickets", ticket_id, {
**existing,
"escalatedTo": "engineering-lead",
"escalatedAt": datetime.now(timezone.utc).isoformat(),
**updates,
})
return existing
async def handler(input_data: Any, ctx: FlowContext[Any]) -> Any:
async def _queue_handler(breach: Any) -> None:
ticket_id = breach.get("ticketId")
age_minutes = breach.get("ageMinutes", 0)
priority = breach.get("priority", "medium")
ctx.logger.info("Escalating ticket", {"ticketId": ticket_id, "triggerType": "queue"})
ctx.logger.warn("Auto-escalation from SLA breach", {
"ticketId": ticket_id,
"ageMinutes": age_minutes,
"priority": priority,
})
escalated = await _escalate_ticket(
ticket_id,
{"escalationReason": f"SLA breach: {age_minutes} minutes without resolution", "escalationMethod": "auto"},
ctx,
)
if not escalated:
ctx.logger.error("Ticket not found during SLA escalation", {"ticketId": ticket_id, "ageMinutes": age_minutes})
async def _http_handler(request: ApiRequest[Any]) -> ApiResponse[Any]:
body = request.body or {}
ticket_id = body.get("ticketId")
reason = body.get("reason", "")
ctx.logger.info("Escalating ticket", {"ticketId": ticket_id, "triggerType": "http"})
existing = await _escalate_ticket(
ticket_id,
{"escalationReason": reason, "escalationMethod": "manual"},
ctx,
)
if not existing:
return ApiResponse(status=404, body={"error": f"Ticket {ticket_id} not found"})
ctx.logger.info("Manual escalation via API", {"ticketId": ticket_id, "reason": reason})
return ApiResponse(status=200, body={
"ticketId": ticket_id,
"escalatedTo": "engineering-lead",
"message": "Ticket escalated successfully",
})
return await ctx.match({
"queue": _queue_handler,
"http": _http_handler,
})

View File

@@ -1,24 +0,0 @@
"""List Tickets Step - returns all tickets from state."""
from typing import Any
from motia import ApiRequest, ApiResponse, FlowContext, http
config = {
"name": "ListTickets",
"description": "Returns all tickets from state",
"flows": ["support-ticket-flow"],
"triggers": [
http("GET", "/tickets"),
],
"enqueues": [],
}
async def handler(request: ApiRequest[Any], ctx: FlowContext[Any]) -> ApiResponse[Any]:
_ = request
tickets = await ctx.state.list("tickets")
ctx.logger.info("Listing tickets", {"count": len(tickets)})
return ApiResponse(status=200, body={"tickets": tickets, "count": len(tickets)})

View File

@@ -1,37 +0,0 @@
"""Notify Customer Step - sends a notification when a ticket has been triaged."""
import re
from typing import Any
from motia import FlowContext, queue
config = {
"name": "NotifyCustomer",
"description": "Sends a notification when a ticket has been triaged",
"flows": ["support-ticket-flow"],
"triggers": [
queue("ticket::triaged"),
],
"enqueues": [],
}
async def handler(input_data: Any, ctx: FlowContext[Any]) -> None:
ticket_id = input_data.get("ticketId")
assignee = input_data.get("assignee")
priority = input_data.get("priority")
title = input_data.get("title")
ctx.logger.info("Sending customer notification", {"ticketId": ticket_id, "assignee": assignee})
ticket = await ctx.state.get("tickets", ticket_id)
customer_email = ticket.get("customerEmail", "") if ticket else ""
redacted_email = re.sub(r"(?<=.{2}).(?=.*@)", "*", customer_email) if customer_email else "unknown"
ctx.logger.info("Notification sent", {
"ticketId": ticket_id,
"assignee": assignee,
"priority": priority,
"title": title,
"email": redacted_email,
})

View File

@@ -1,67 +0,0 @@
"""SLA Monitor Step - cron job that checks for SLA breaches on open tickets."""
from datetime import datetime, timezone
from typing import Any
from motia import FlowContext, cron
SLA_THRESHOLDS_MS = {
"critical": 15 * 60 * 1000, # 15 minutes
"high": 60 * 60 * 1000, # 1 hour
"medium": 4 * 60 * 60 * 1000, # 4 hours
"low": 24 * 60 * 60 * 1000, # 24 hours
}
config = {
"name": "SlaMonitor",
"description": "Cron job that checks for SLA breaches on open tickets",
"flows": ["support-ticket-flow"],
"triggers": [
cron("0/30 * * * * *"),
],
"enqueues": ["ticket::sla-breached"],
}
async def handler(input_data: None, ctx: FlowContext[Any]) -> None:
_ = input_data
ctx.logger.info("Running SLA compliance check")
tickets = await ctx.state.list("tickets")
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
breaches = 0
for ticket in tickets:
if ticket.get("status") != "open" or not ticket.get("createdAt"):
continue
try:
created_dt = datetime.fromisoformat(ticket["createdAt"])
created_ms = int(created_dt.timestamp() * 1000)
except (ValueError, TypeError):
continue
age_ms = now_ms - created_ms
threshold = SLA_THRESHOLDS_MS.get(ticket.get("priority", "medium"), SLA_THRESHOLDS_MS["medium"])
if age_ms > threshold:
breaches += 1
age_minutes = round(age_ms / 60_000)
ctx.logger.warn("SLA breach detected!", {
"ticketId": ticket["id"],
"priority": ticket.get("priority"),
"ageMinutes": age_minutes,
})
await ctx.enqueue({
"topic": "ticket::sla-breached",
"data": {
"ticketId": ticket["id"],
"priority": ticket.get("priority", "medium"),
"title": ticket.get("title", ""),
"ageMinutes": age_minutes,
},
})
ctx.logger.info("SLA check complete", {"totalTickets": len(tickets), "breaches": breaches})

View File

@@ -1,100 +0,0 @@
"""Triage Ticket Step - multi-trigger: auto-triage from queue, manual triage via API, sweep via cron.
Demonstrates a single step responding to three trigger types using ctx.match().
"""
from datetime import datetime, timezone
from typing import Any
from motia import ApiRequest, ApiResponse, FlowContext, cron, http, queue
config = {
"name": "TriageTicket",
"description": "Multi-trigger: auto-triage from queue, manual triage via API, sweep via cron",
"flows": ["support-ticket-flow"],
"triggers": [
queue("ticket::created"),
http("POST", "/tickets/triage"),
cron("0 */5 * * * * *"),
],
"enqueues": ["ticket::triaged"],
}
async def _triage_ticket(
ticket_id: str,
existing: dict[str, Any] | None,
state_updates: dict[str, Any],
enqueue_data: dict[str, Any],
ctx: FlowContext[Any],
) -> None:
"""Updates ticket state with triage fields and emits the triaged event."""
if not existing:
return
updated = {**existing, "triagedAt": datetime.now(timezone.utc).isoformat(), **state_updates}
await ctx.state.set("tickets", ticket_id, updated)
await ctx.enqueue({"topic": "ticket::triaged", "data": {"ticketId": ticket_id, **enqueue_data}})
async def handler(input_data: Any, ctx: FlowContext[Any]) -> Any:
async def _queue_handler(data: Any) -> None:
ticket_id = data.get("ticketId")
title = data.get("title", "")
priority = data.get("priority", "medium")
ctx.logger.info("Auto-triaging ticket from queue", {"ticketId": ticket_id, "priority": priority})
assignee = "senior-support" if priority in ("critical", "high") else "support-pool"
existing = await ctx.state.get("tickets", ticket_id)
await _triage_ticket(
ticket_id, existing,
{"assignee": assignee, "triageMethod": "auto"},
{"assignee": assignee, "priority": priority, "title": title},
ctx,
)
ctx.logger.info("Ticket auto-triaged", {"ticketId": ticket_id, "assignee": assignee})
async def _http_handler(request: ApiRequest[Any]) -> ApiResponse[Any]:
body = request.body or {}
ticket_id = body.get("ticketId")
assignee = body.get("assignee")
priority = body.get("priority", "medium")
existing = await ctx.state.get("tickets", ticket_id)
if not existing:
return ApiResponse(status=404, body={"error": f"Ticket {ticket_id} not found"})
ctx.logger.info("Manual triage via API", {"ticketId": ticket_id, "assignee": assignee})
await _triage_ticket(
ticket_id, existing,
{"assignee": assignee, "priority": priority, "triageMethod": "manual"},
{"assignee": assignee, "priority": priority, "title": existing.get("title", "")},
ctx,
)
return ApiResponse(status=200, body={"ticketId": ticket_id, "assignee": assignee, "status": "triaged"})
async def _cron_handler() -> None:
ctx.logger.info("Running untriaged ticket sweep.")
tickets = await ctx.state.list("tickets")
swept = 0
for ticket in tickets:
if not ticket.get("assignee") and ticket.get("status") == "open":
ctx.logger.warn("Found untriaged ticket during sweep", {"ticketId": ticket["id"]})
await _triage_ticket(
ticket["id"], ticket,
{"assignee": "support-pool", "triageMethod": "auto-sweep"},
{"assignee": "support-pool", "priority": ticket.get("priority", "medium"), "title": ticket.get("title", "unknown")},
ctx,
)
swept += 1
ctx.logger.info("Sweep complete", {"sweptCount": swept})
return await ctx.match({
"queue": _queue_handler,
"http": _http_handler,
"cron": _cron_handler,
})

110
tests/README.md Normal file
View File

@@ -0,0 +1,110 @@
# Test Scripts
This directory contains test scripts for the Motia III xAI Collections integration.
## Test Files
### `test_xai_collections_api.py`
Tests xAI Collections API authentication and basic operations.
**Usage:**
```bash
cd /opt/motia-iii/bitbylaw
python tests/test_xai_collections_api.py
```
**Required Environment Variables:**
- `XAI_MANAGEMENT_API_KEY` - xAI Management API key for collection operations
- `XAI_API_KEY` - xAI Regular API key for file operations
**Tests:**
- ✅ Management API authentication
- ✅ Regular API authentication
- ✅ Collection listing
- ✅ Collection creation
- ✅ File upload
- ✅ Collection deletion
- ✅ Error handling
### `test_preview_upload.py`
Tests preview/thumbnail upload to EspoCRM CDokumente entity.
**Usage:**
```bash
cd /opt/motia-iii/bitbylaw
python tests/test_preview_upload.py
```
**Required Environment Variables:**
- `ESPOCRM_URL` - EspoCRM instance URL (default: https://crm.bitbylaw.com)
- `ESPOCRM_API_KEY` - EspoCRM API key
**Tests:**
- ✅ Preview image generation (WebP format, 600x800px)
- ✅ Base64 Data URI encoding
- ✅ Attachment upload via JSON POST
- ✅ Entity update with previewId/previewName
**Status:** ✅ Successfully tested - Attachment ID `69a71194c7c6baebf` created
### `test_thumbnail_generation.py`
Tests thumbnail generation for various document types.
**Usage:**
```bash
cd /opt/motia-iii/bitbylaw
python tests/test_thumbnail_generation.py
```
**Supported Formats:**
- PDF → WebP (first page)
- DOCX/DOC → PDF → WebP
- Images (JPEG, PNG, etc.) → WebP resize
**Dependencies:**
- `python3-pil` - PIL/Pillow for image processing
- `poppler-utils` - PDF rendering
- `libreoffice` - DOCX to PDF conversion
- `pdf2image` - PDF to image conversion
## Running Tests
### All Tests
```bash
cd /opt/motia-iii/bitbylaw
python -m pytest tests/ -v
```
### Individual Tests
```bash
cd /opt/motia-iii/bitbylaw
python tests/test_xai_collections_api.py
python tests/test_preview_upload.py
python tests/test_thumbnail_generation.py
```
## Environment Setup
Create `.env` file in `/opt/motia-iii/bitbylaw/`:
```bash
# xAI Collections API
XAI_MANAGEMENT_API_KEY=xai-token-xxx...
XAI_API_KEY=xai-xxx...
# EspoCRM API
ESPOCRM_URL=https://crm.bitbylaw.com
ESPOCRM_API_KEY=xxx...
# Redis (for locking)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB_ADVOWARE_CACHE=1
```
## Test Results
Last test run: Successfully validated preview upload functionality
- Preview upload works with base64 Data URI format
- Attachment created with ID: `69a71194c7c6baebf`
- CDokumente entity updated with previewId/previewName
- WebP format at 600x800px confirmed working

279
tests/test_preview_upload.py Executable file
View File

@@ -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 <document_id>
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]} <document_id>")
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())

View File

@@ -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())

788
tests/test_xai_collections_api.py Executable file
View File

@@ -0,0 +1,788 @@
#!/usr/bin/env python3
"""
xAI Collections API Test Script
Tests all critical operations for our document sync requirements:
1. File upload and ID behavior (collection-specific vs global?)
2. Same file in multiple collections (shared file_id?)
3. CRUD operations on collections
4. CRUD operations on documents
5. Response structures and metadata
6. Update/versioning behavior
Usage:
export XAI_API_KEY="xai-..."
python test_xai_collections_api.py
"""
import os
import sys
import json
import asyncio
import aiohttp
from typing import Optional, Dict, Any, List
from datetime import datetime
import tempfile
# Configuration
XAI_MANAGEMENT_URL = os.getenv("XAI_MANAGEMENT_URL", "https://management-api.x.ai")
XAI_FILES_URL = os.getenv("XAI_FILES_URL", "https://api.x.ai")
XAI_MANAGEMENT_KEY = os.getenv("XAI_MANAGEMENT_KEY", "") # Management API Key
XAI_API_KEY = os.getenv("XAI_API_KEY", "") # Regular API Key for file upload
if not XAI_MANAGEMENT_KEY:
print("❌ ERROR: XAI_MANAGEMENT_KEY environment variable not set!")
print(" export XAI_MANAGEMENT_KEY='xai-token-...'")
sys.exit(1)
if not XAI_API_KEY:
print("❌ ERROR: XAI_API_KEY environment variable not set!")
print(" export XAI_API_KEY='xai-...'")
sys.exit(1)
class Colors:
"""ANSI color codes for terminal output"""
HEADER = '\033[95m'
BLUE = '\033[94m'
CYAN = '\033[96m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
END = '\033[0m'
def print_header(text: str):
print(f"\n{Colors.BOLD}{Colors.CYAN}{'='*70}{Colors.END}")
print(f"{Colors.BOLD}{Colors.CYAN}{text}{Colors.END}")
print(f"{Colors.BOLD}{Colors.CYAN}{'='*70}{Colors.END}\n")
def print_success(text: str):
print(f"{Colors.GREEN}{text}{Colors.END}")
def print_error(text: str):
print(f"{Colors.RED}{text}{Colors.END}")
def print_info(text: str):
print(f"{Colors.BLUE} {text}{Colors.END}")
def print_warning(text: str):
print(f"{Colors.YELLOW}⚠️ {text}{Colors.END}")
def print_json(data: Any, title: Optional[str] = None):
if title:
print(f"{Colors.BOLD}{title}:{Colors.END}")
print(json.dumps(data, indent=2, ensure_ascii=False))
class XAICollectionsTestClient:
"""Test client for xAI Collections API"""
def __init__(self):
self.management_url = XAI_MANAGEMENT_URL
self.files_url = XAI_FILES_URL
self.management_key = XAI_MANAGEMENT_KEY
self.api_key = XAI_API_KEY
self.session: Optional[aiohttp.ClientSession] = None
# Test state
self.created_collections: List[str] = []
self.uploaded_files: List[str] = []
self.test_results: Dict[str, bool] = {}
async def __aenter__(self):
# Session without default Content-Type (set per-request)
self.session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
return self
async def __aexit__(self, *args):
if self.session:
await self.session.close()
async def _request(self, method: str, path: str, use_files_api: bool = False, **kwargs) -> tuple[int, Any]:
"""Make HTTP request and return (status, response_data)"""
base_url = self.files_url if use_files_api else self.management_url
url = f"{base_url}{path}"
# Set headers per-request
if 'headers' not in kwargs:
kwargs['headers'] = {}
# Set authorization
if use_files_api:
kwargs['headers']['Authorization'] = f"Bearer {self.api_key}"
else:
kwargs['headers']['Authorization'] = f"Bearer {self.management_key}"
# Set Content-Type for JSON requests
if 'json' in kwargs:
kwargs['headers']['Content-Type'] = 'application/json'
print_info(f"{method} {url}")
print_info(f"Headers: {kwargs.get('headers', {})}")
try:
async with self.session.request(method, url, **kwargs) as response:
status = response.status
try:
data = await response.json()
except:
text = await response.text()
data = {"_raw_text": text} if text else {}
if status < 400:
print_success(f"Response: {status}")
else:
print_error(f"Response: {status}")
return status, data
except Exception as e:
print_error(f"Request failed: {e}")
return 0, {"error": str(e)}
# ========================================================================
# COLLECTION OPERATIONS
# ========================================================================
async def create_collection(self, name: str, metadata: Optional[Dict] = None) -> tuple[int, Any]:
"""POST /v1/collections"""
payload = {
"collection_name": name, # xAI uses "collection_name" not "name"
"metadata": metadata or {}
}
status, data = await self._request("POST", "/v1/collections", json=payload)
if status == 200 or status == 201:
# Try different possible field names for collection ID
collection_id = data.get("id") or data.get("collection_id") or data.get("collectionId")
if collection_id:
self.created_collections.append(collection_id)
print_success(f"Created collection: {collection_id}")
return status, data
async def get_collection(self, collection_id: str) -> tuple[int, Any]:
"""GET /v1/collections/{collection_id}"""
return await self._request("GET", f"/v1/collections/{collection_id}")
async def list_collections(self) -> tuple[int, Any]:
"""GET /v1/collections"""
return await self._request("GET", "/v1/collections")
async def update_collection(self, collection_id: str, name: Optional[str] = None,
metadata: Optional[Dict] = None) -> tuple[int, Any]:
"""PUT /v1/collections/{collection_id}"""
payload = {}
if name:
payload["collection_name"] = name # xAI uses "collection_name"
if metadata:
payload["metadata"] = metadata
return await self._request("PUT", f"/v1/collections/{collection_id}", json=payload)
async def delete_collection(self, collection_id: str) -> tuple[int, Any]:
"""DELETE /v1/collections/{collection_id}"""
status, data = await self._request("DELETE", f"/v1/collections/{collection_id}")
if status == 200 or status == 204:
if collection_id in self.created_collections:
self.created_collections.remove(collection_id)
return status, data
# ========================================================================
# FILE OPERATIONS (multiple upload methods)
# ========================================================================
async def upload_file_multipart(self, content: bytes, filename: str,
mime_type: str = "text/plain") -> tuple[int, Any]:
"""
Method 0: Multipart form-data upload (what the server actually expects!)
POST /v1/files with multipart/form-data
"""
print_info("METHOD 0: Multipart Form-Data Upload (POST /v1/files)")
# Create multipart form data
form = aiohttp.FormData()
form.add_field('file', content, filename=filename, content_type=mime_type)
print_info(f"Uploading {len(content)} bytes as multipart/form-data")
# Use _request but with form data instead of json
base_url = self.files_url
url = f"{base_url}/v1/files"
headers = {
"Authorization": f"Bearer {self.api_key}"
# Do NOT set Content-Type - aiohttp will set it with boundary
}
print_info(f"POST {url}")
print_info(f"Headers: {headers}")
try:
async with self.session.request("POST", url, data=form, headers=headers) as response:
status = response.status
try:
data = await response.json()
except:
text = await response.text()
data = {"_raw_text": text} if text else {}
if status < 400:
print_success(f"Response: {status}")
else:
print_error(f"Response: {status}")
return status, data
except Exception as e:
print_error(f"Request failed: {e}")
return 0, {"error": str(e)}
async def upload_file_direct(self, content: bytes, filename: str,
mime_type: str = "text/plain") -> tuple[int, Any]:
"""
Method 1: Direct upload to xAI Files API
POST /v1/files with JSON body containing base64-encoded data
"""
import base64
print_info("METHOD 1: Direct Upload (POST /v1/files with JSON)")
# Encode file content as base64
data_b64 = base64.b64encode(content).decode('ascii')
payload = {
"name": filename,
"content_type": mime_type,
"data": data_b64
}
print_info(f"Uploading {len(content)} bytes as base64 ({len(data_b64)} chars)")
status, data = await self._request(
"POST",
"/v1/files",
use_files_api=True,
json=payload
)
return status, data
async def upload_file_chunked(self, content: bytes, filename: str,
mime_type: str = "text/plain") -> tuple[int, Any]:
"""
Method 2: Initialize + Chunk streaming upload
POST /v1/files:initialize → POST /v1/files:uploadChunks
"""
import base64
print_info("METHOD 2: Initialize + Chunk Streaming")
# Step 1: Initialize upload
print_info("Step 1: Initialize upload")
init_payload = {
"name": filename,
"content_type": mime_type
}
status, data = await self._request(
"POST",
"/v1/files:initialize",
use_files_api=True,
json=init_payload
)
print_json(data, "Initialize Response")
if status not in [200, 201]:
print_error("Failed to initialize upload")
return status, data
file_id = data.get("file_id")
if not file_id:
print_error("No file_id in initialize response")
return status, data
print_success(f"Initialized upload with file_id: {file_id}")
# Step 2: Upload chunks
print_info(f"Step 2: Upload {len(content)} bytes in chunks")
# Encode content as base64 for chunk upload
chunk_b64 = base64.b64encode(content).decode('ascii')
chunk_payload = {
"file_id": file_id,
"chunk": chunk_b64
}
status, data = await self._request(
"POST",
"/v1/files:uploadChunks",
use_files_api=True,
json=chunk_payload
)
print_json(data, "Upload Chunks Response")
if status in [200, 201]:
print_success(f"Uploaded file chunks: {file_id}")
self.uploaded_files.append(file_id)
return status, data
async def upload_file(self, content: bytes, filename: str,
mime_type: str = "text/plain") -> tuple[int, Any]:
"""
Try multiple upload methods until one succeeds
"""
print_info("Trying upload methods...")
# Try Method 0: Multipart form-data (what the server really wants!)
status0, data0 = await self.upload_file_multipart(content, filename, mime_type)
if status0 in [200, 201]:
file_id = data0.get("id") or data0.get("file_id") # Try both field names
if file_id:
self.uploaded_files.append(file_id)
print_success(f"✅ Multipart upload succeeded: {file_id}")
return status0, data0
else:
print_error("No 'id' or 'file_id' in response")
print_json(data0, "Response data")
print_warning(f"Multipart upload failed ({status0}), trying JSON upload...")
# Try Method 1: Direct upload with JSON
status1, data1 = await self.upload_file_direct(content, filename, mime_type)
if status1 in [200, 201]:
file_id = data1.get("file_id")
if file_id:
self.uploaded_files.append(file_id)
print_success(f"✅ Direct upload succeeded: {file_id}")
return status1, data1
print_warning(f"Direct upload failed ({status1}), trying chunked upload...")
# Try Method 2: Initialize + Chunks
status2, data2 = await self.upload_file_chunked(content, filename, mime_type)
if status2 in [200, 201]:
print_success("✅ Chunked upload succeeded")
return status2, data2
print_error("❌ All upload methods failed")
return status0, data0 # Return multipart method's error
# ========================================================================
# COLLECTION DOCUMENT OPERATIONS
# ========================================================================
async def add_document_to_collection(self, collection_id: str,
file_id: str) -> tuple[int, Any]:
"""POST /v1/collections/{collection_id}/documents/{file_id}"""
return await self._request("POST",
f"/v1/collections/{collection_id}/documents/{file_id}")
async def get_collection_documents(self, collection_id: str) -> tuple[int, Any]:
"""GET /v1/collections/{collection_id}/documents"""
return await self._request("GET",
f"/v1/collections/{collection_id}/documents")
async def get_collection_document(self, collection_id: str,
file_id: str) -> tuple[int, Any]:
"""GET /v1/collections/{collection_id}/documents/{file_id}"""
return await self._request("GET",
f"/v1/collections/{collection_id}/documents/{file_id}")
async def update_collection_document(self, collection_id: str, file_id: str,
metadata: Dict) -> tuple[int, Any]:
"""PATCH /v1/collections/{collection_id}/documents/{file_id}"""
return await self._request("PATCH",
f"/v1/collections/{collection_id}/documents/{file_id}",
json={"metadata": metadata})
async def remove_document_from_collection(self, collection_id: str,
file_id: str) -> tuple[int, Any]:
"""DELETE /v1/collections/{collection_id}/documents/{file_id}"""
return await self._request("DELETE",
f"/v1/collections/{collection_id}/documents/{file_id}")
async def batch_get_documents(self, collection_id: str,
file_ids: List[str]) -> tuple[int, Any]:
"""GET /v1/collections/{collection_id}/documents:batchGet"""
params = {"fileIds": ",".join(file_ids)}
return await self._request("GET",
f"/v1/collections/{collection_id}/documents:batchGet",
params=params)
# ========================================================================
# TEST SCENARIOS
# ========================================================================
async def test_basic_collection_crud(self):
"""Test 1: Basic Collection CRUD operations"""
print_header("TEST 1: Basic Collection CRUD")
# Create
print_info("Creating collection...")
status, data = await self.create_collection(
name="Test Collection 1",
metadata={"test": True, "purpose": "API testing"}
)
print_json(data, "Response")
if status not in [200, 201]:
print_error("Failed to create collection")
self.test_results["collection_crud"] = False
return None
# Try different possible field names for collection ID
collection_id = data.get("id") or data.get("collection_id") or data.get("collectionId")
if not collection_id:
print_error("No collection ID field in response")
print_json(data, "Response Data")
self.test_results["collection_crud"] = False
return None
print_success(f"Collection created: {collection_id}")
# Read
print_info("Reading collection...")
status, data = await self.get_collection(collection_id)
print_json(data, "Response")
# Update
print_info("Updating collection...")
status, data = await self.update_collection(
collection_id,
name="Test Collection 1 (Updated)",
metadata={"test": True, "updated": True}
)
print_json(data, "Response")
self.test_results["collection_crud"] = True
return collection_id
async def test_file_upload_and_structure(self, collection_id: str):
"""Test 2: File upload (two-step process)"""
print_header("TEST 2: File Upload (Two-Step) & Response Structure")
# Create test file content
test_content = b"""
This is a test document for xAI Collections API testing.
Topic: German Contract Law
Key Points:
- Contracts require offer and acceptance
- Consideration is necessary
- Written form may be required for certain contracts
This document contains sufficient content for testing.
"""
# STEP 1: Upload file to Files API
print_info("STEP 1: Uploading file to Files API (api.x.ai)...")
status, data = await self.upload_file(
content=test_content,
filename="test_document.txt",
mime_type="text/plain"
)
print_json(data, "Files API Upload Response")
if status not in [200, 201]:
print_error("File upload to Files API failed")
self.test_results["file_upload"] = False
return None
# Try both field names: 'id' (Files API) or 'file_id' (Collections API)
file_id = data.get("id") or data.get("file_id")
if not file_id:
print_error("No 'id' or 'file_id' field in response")
print_json(data, "Response for debugging")
self.test_results["file_upload"] = False
return None
print_success(f"File uploaded to Files API: {file_id}")
# STEP 2: Add file to collection using Management API
print_info("STEP 2: Adding file to collection (management-api.x.ai)...")
status2, data2 = await self.add_document_to_collection(collection_id, file_id)
print_json(data2, "Add to Collection Response")
if status2 not in [200, 201]:
print_error("Failed to add file to collection")
self.test_results["file_upload"] = False
return None
print_success(f"File added to collection: {file_id}")
self.test_results["file_upload"] = True
return file_id
async def test_document_in_collection(self, collection_id: str, file_id: str):
"""Test 3: Verify document is in collection and get details"""
print_header("TEST 3: Verify Document in Collection")
# Verify by listing documents
print_info("Listing collection documents...")
status, data = await self.get_collection_documents(collection_id)
print_json(data, "Collection Documents")
if status not in [200, 201]:
print_error("Failed to list documents")
self.test_results["add_to_collection"] = False
return False
# Get specific document
print_info("Getting specific document...")
status, data = await self.get_collection_document(collection_id, file_id)
print_json(data, "Document Details")
if status not in [200, 201]:
print_error("Failed to get document details")
self.test_results["add_to_collection"] = False
return False
print_success("Document verified in collection")
self.test_results["add_to_collection"] = True
return True
async def test_shared_file_across_collections(self, file_id: str):
"""Test 4: CRITICAL - Can same file_id be used in multiple collections?"""
print_header("TEST 4: Shared File Across Collections (CRITICAL)")
# Create second collection
print_info("Creating second collection...")
status, data = await self.create_collection(
name="Test Collection 2",
metadata={"test": True, "purpose": "Multi-collection test"}
)
if status not in [200, 201]:
print_error("Failed to create second collection")
self.test_results["shared_file"] = False
return
collection2_id = data.get("collection_id") or data.get("id")
print_success(f"Collection 2 created: {collection2_id}")
# Try to add SAME file_id to second collection
print_info(f"Adding SAME file_id {file_id} to collection 2...")
status, data = await self.add_document_to_collection(collection2_id, file_id)
print_json(data, "Response from adding existing file_id to second collection")
if status not in [200, 201]:
print_error("Failed to add same file to second collection")
print_warning("⚠️ Files might be collection-specific (BAD for our use case)")
self.test_results["shared_file"] = False
return
print_success("✅ SAME FILE_ID CAN BE USED IN MULTIPLE COLLECTIONS!")
print_success("✅ This is PERFECT for our architecture!")
# Verify both collections have the file
print_info("Verifying file in both collections...")
status1, data1 = await self.get_collection_documents(self.created_collections[0])
status2, data2 = await self.get_collection_documents(collection2_id)
print_json(data1, "Collection 1 Documents")
print_json(data2, "Collection 2 Documents")
# Extract file_ids from both collections to verify they match
docs1 = data1.get("documents", [])
docs2 = data2.get("documents", [])
file_ids_1 = [d.get("file_metadata", {}).get("file_id") for d in docs1]
file_ids_2 = [d.get("file_metadata", {}).get("file_id") for d in docs2]
if file_id in file_ids_1 and file_id in file_ids_2:
print_success(f"✅ CONFIRMED: file_id {file_id} is IDENTICAL in both collections!")
print_info(" → We can store ONE xaiFileId per document!")
print_info(" → Simply track which collections contain it!")
self.test_results["shared_file"] = True
async def test_document_update(self, collection_id: str, file_id: str):
"""Test 5: Update document metadata"""
print_header("TEST 5: Update Document Metadata")
print_info("Updating document metadata...")
status, data = await self.update_collection_document(
collection_id,
file_id,
metadata={"updated_at": datetime.now().isoformat(), "version": 2}
)
print_json(data, "Update Response")
if status not in [200, 201]:
print_error("Failed to update document")
self.test_results["document_update"] = False
return
print_success("Document metadata updated")
self.test_results["document_update"] = True
async def test_document_removal(self):
"""Test 6: Remove document from collection"""
print_header("TEST 6: Remove Document from Collection")
if len(self.created_collections) < 2 or not self.uploaded_files:
print_warning("Skipping - need at least 2 collections and 1 file")
return
collection_id = self.created_collections[0]
file_id = self.uploaded_files[0]
print_info(f"Removing file {file_id} from collection {collection_id}...")
status, data = await self.remove_document_from_collection(collection_id, file_id)
print_json(data, "Response")
if status not in [200, 204]:
print_error("Failed to remove document")
self.test_results["document_removal"] = False
return
print_success("Document removed from collection")
# Verify removal
print_info("Verifying removal...")
status, data = await self.get_collection_documents(collection_id)
print_json(data, "Remaining Documents")
self.test_results["document_removal"] = True
async def test_batch_get(self):
"""Test 7: Batch get documents"""
print_header("TEST 7: Batch Get Documents")
if not self.created_collections or not self.uploaded_files:
print_warning("Skipping - need collections and files")
return
collection_id = self.created_collections[-1] # Use last collection
file_ids = self.uploaded_files
if not file_ids:
print_warning("No file IDs to batch get")
return
print_info(f"Batch getting {len(file_ids)} documents...")
status, data = await self.batch_get_documents(collection_id, file_ids)
print_json(data, "Batch Response")
self.test_results["batch_get"] = status in [200, 201]
async def cleanup(self):
"""Clean up all created test resources"""
print_header("CLEANUP: Deleting Test Resources")
# Delete collections (should cascade delete documents?)
for collection_id in list(self.created_collections):
print_info(f"Deleting collection {collection_id}...")
await self.delete_collection(collection_id)
print_success("Cleanup complete")
def print_summary(self):
"""Print test results summary"""
print_header("TEST RESULTS SUMMARY")
total = len(self.test_results)
passed = sum(1 for v in self.test_results.values() if v)
for test_name, result in self.test_results.items():
status = "✅ PASS" if result else "❌ FAIL"
print(f"{status} - {test_name}")
print(f"\n{Colors.BOLD}Total: {passed}/{total} tests passed{Colors.END}\n")
# Critical findings
print_header("CRITICAL FINDINGS")
if "shared_file" in self.test_results:
if self.test_results["shared_file"]:
print_success("✅ Same file CAN be used in multiple collections")
print_info(" → We can use a SINGLE xaiFileId per document!")
print_info(" → Much simpler architecture!")
else:
print_error("❌ Files seem to be collection-specific")
print_warning(" → More complex mapping required")
print_warning(" → Each collection might need separate file upload")
async def main():
"""Run all tests"""
print_header("xAI Collections API Test Suite")
print_info(f"Management URL: {XAI_MANAGEMENT_URL}")
print_info(f"Files URL: {XAI_FILES_URL}")
print_info(f"Management Key: {XAI_MANAGEMENT_KEY[:20]}...{XAI_MANAGEMENT_KEY[-4:]}")
print_info(f"API Key: {XAI_API_KEY[:20]}...{XAI_API_KEY[-4:]}")
async with XAICollectionsTestClient() as client:
try:
# Test 1: Basic Collection CRUD
collection_id = await client.test_basic_collection_crud()
if not collection_id:
print_error("Cannot continue without collection. Stopping.")
return
# Test 2: File Upload (now two-step process)
file_id = await client.test_file_upload_and_structure(collection_id)
if not file_id:
print_error("File upload failed. Continuing with remaining tests...")
else:
# Test 3: Verify document in collection
await client.test_document_in_collection(collection_id, file_id)
# Test 4: CRITICAL - Shared file test
await client.test_shared_file_across_collections(file_id)
# Test 5: Update document
await client.test_document_update(collection_id, file_id)
# Test 6: Remove document
await client.test_document_removal()
# Test 7: Batch get
await client.test_batch_get()
# Cleanup
await client.cleanup()
# Print summary
client.print_summary()
except Exception as e:
print_error(f"Test suite failed: {e}")
import traceback
traceback.print_exc()
# Try cleanup anyway
try:
await client.cleanup()
except:
pass
if __name__ == "__main__":
asyncio.run(main())

2093
uv.lock generated

File diff suppressed because it is too large Load Diff