Files
leocrm/app/services/webhook_service.py
T
Agent Zero 79ece0fe2e Phase 4: Webhooks, Backup/Restore UI, Onboarding/Tutorial
- Webhooks Backend: model, schema, service (HMAC-SHA256, httpx), routes, event bus dispatcher, migration 0042
- Webhooks Frontend: SettingsWebhooksPage (CRUD, test button, event multi-select), API client
- Backup/Restore Backend: model, schema, service (pg_dump/pg_restore), routes (admin-only), migration 0043
- Backup/Restore Frontend: SettingsBackupPage (create, list, restore dialog with RESTORE confirmation, auto-refresh)
- Onboarding: OnboardingTour (8 steps, custom CSS overlay), WelcomeDialog, onboardingStore (zustand + localStorage)
- Onboarding integrated into AppShell
- Routes: /settings/webhooks, /settings/backup registered
- Settings nav: Webhooks, Backup & Restore entries added
- Migration conflict fixed: 0042_webhooks → 0043_backups chain
2026-07-26 03:17:40 +02:00

184 lines
4.9 KiB
Python

"""CRUD service for Webhook subscriptions and delivery."""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import uuid
from typing import Any
import httpx
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.webhook import Webhook
logger = logging.getLogger(__name__)
async def list_webhooks(
db: AsyncSession,
tenant_id: uuid.UUID,
event: str | None = None,
) -> list[Webhook]:
"""List webhooks for a tenant, optionally filtered by event."""
stmt = select(Webhook).where(
Webhook.tenant_id == tenant_id,
)
if event:
stmt = stmt.where(Webhook.events.any(event))
stmt = stmt.order_by(Webhook.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
) -> Webhook | None:
"""Get a single webhook by ID."""
stmt = select(Webhook).where(
Webhook.id == webhook_id,
Webhook.tenant_id == tenant_id,
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def create_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> Webhook:
"""Create a new webhook subscription."""
webhook = Webhook(
tenant_id=tenant_id,
url=data["url"],
events=data["events"],
secret=data.get("secret"),
is_active=data.get("is_active", True),
retry_count=data.get("retry_count", 3),
timeout_seconds=data.get("timeout_seconds", 30),
created_by=user_id,
updated_by=user_id,
)
db.add(webhook)
await db.flush()
await db.refresh(webhook)
return webhook
async def update_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
data: dict[str, Any],
user_id: uuid.UUID | None = None,
) -> Webhook | None:
"""Update an existing webhook subscription."""
webhook = await get_webhook(db, tenant_id, webhook_id)
if webhook is None:
return None
update_fields = ["url", "events", "secret", "is_active", "retry_count", "timeout_seconds"]
for field in update_fields:
if field in data:
setattr(webhook, field, data[field])
if user_id is not None:
webhook.updated_by = user_id
await db.flush()
await db.refresh(webhook)
return webhook
async def delete_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
) -> bool:
"""Delete a webhook subscription."""
stmt = select(Webhook).where(
Webhook.id == webhook_id,
Webhook.tenant_id == tenant_id,
)
result = await db.execute(stmt)
webhook = result.scalar_one_or_none()
if webhook is None:
return False
await db.delete(webhook)
await db.flush()
return True
def _sign_payload(payload: bytes, secret: str) -> str:
"""Create HMAC-SHA256 signature for a payload."""
return hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
async def send_webhook(
webhook: Webhook,
event_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Send a webhook HTTP POST with HMAC signature.
Returns a dict with 'success' (bool), 'status_code' (int | None),
and 'error' (str | None).
"""
body = {
"event": event_name,
"payload": payload,
"timestamp": __import__("datetime").datetime.utcnow().isoformat() + "Z",
}
body_bytes = json.dumps(body, default=str).encode("utf-8")
headers = {
"Content-Type": "application/json",
"User-Agent": "LeoCRM-Webhook/1.0",
}
if webhook.secret:
signature = _sign_payload(body_bytes, webhook.secret)
headers["X-Webhook-Signature"] = f"sha256={signature}"
try:
async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client:
response = await client.post(
webhook.url,
content=body_bytes,
headers=headers,
)
return {
"success": response.is_success,
"status_code": response.status_code,
"error": None,
}
except httpx.TimeoutException:
return {"success": False, "status_code": None, "error": "timeout"}
except httpx.RequestError as exc:
return {"success": False, "status_code": None, "error": str(exc)}
async def retry_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
event_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Retry sending a webhook with the given event and payload."""
webhook = await get_webhook(db, tenant_id, webhook_id)
if webhook is None:
return {"success": False, "status_code": None, "error": "webhook_not_found"}
return await send_webhook(webhook, event_name, payload)