mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-01 01:40:59 -05:00
a18de04a6a
Implement comprehensive webhook system supporting 40+ event types with automatic retries, HMAC signatures, delivery tracking, REST API, and admin UI. Integrates with Activity logging for automatic event triggering. - Database: Add webhooks and webhook_deliveries tables (migration 046) - API: Full CRUD endpoints with read:webhooks/write:webhooks scopes - UI: Admin interface for webhook management and testing - Service: Automatic retry with exponential backoff every 5 minutes - Security: HMAC-SHA256 signature verification - Tests: Model and service tests included - Docs: Complete integration guide with examples
175 lines
6.1 KiB
Python
175 lines
6.1 KiB
Python
"""Webhook event dispatcher - integrates with Activity system to trigger webhooks"""
|
|
import logging
|
|
from typing import Dict, Any, Optional
|
|
from flask import current_app
|
|
from app import db
|
|
from app.models.webhook import Webhook
|
|
from app.utils.webhook_service import WebhookService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WebhookDispatcher:
|
|
"""Dispatcher for triggering webhooks based on system events"""
|
|
|
|
@staticmethod
|
|
def dispatch_event(event_type: str, payload: Dict[str, Any],
|
|
event_id: Optional[str] = None,
|
|
user_id: Optional[int] = None):
|
|
"""Dispatch a webhook event to all active webhooks that subscribe to it
|
|
|
|
Args:
|
|
event_type: Event type (e.g., 'project.created')
|
|
payload: Event payload dictionary
|
|
event_id: Optional unique event ID for deduplication
|
|
user_id: Optional user ID who triggered the event
|
|
"""
|
|
try:
|
|
# Find all active webhooks that subscribe to this event
|
|
webhooks = Webhook.query.filter(
|
|
Webhook.is_active == True
|
|
).all()
|
|
|
|
triggered_count = 0
|
|
|
|
for webhook in webhooks:
|
|
if webhook.subscribes_to(event_type):
|
|
try:
|
|
WebhookService.deliver_webhook(
|
|
webhook=webhook,
|
|
event_type=event_type,
|
|
payload=payload,
|
|
event_id=event_id
|
|
)
|
|
triggered_count += 1
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to deliver webhook {webhook.id} for event {event_type}: {e}",
|
|
exc_info=True
|
|
)
|
|
|
|
if triggered_count > 0:
|
|
logger.debug(f"Dispatched {event_type} to {triggered_count} webhook(s)")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error dispatching webhook event {event_type}: {e}", exc_info=True)
|
|
|
|
@staticmethod
|
|
def map_activity_to_event(action: str, entity_type: str) -> Optional[str]:
|
|
"""Map Activity action and entity_type to webhook event type
|
|
|
|
Args:
|
|
action: Activity action (e.g., 'created', 'updated')
|
|
entity_type: Entity type (e.g., 'project', 'task')
|
|
|
|
Returns:
|
|
str: Webhook event type or None if not mappable
|
|
"""
|
|
# Map common actions
|
|
action_map = {
|
|
'created': 'created',
|
|
'updated': 'updated',
|
|
'deleted': 'deleted',
|
|
'archived': 'archived',
|
|
'unarchived': 'unarchived',
|
|
'started': 'started',
|
|
'stopped': 'stopped',
|
|
'completed': 'completed',
|
|
'assigned': 'assigned',
|
|
'status_changed': 'status_changed',
|
|
'sent': 'sent',
|
|
'paid': 'paid',
|
|
'overdue': 'overdue',
|
|
}
|
|
|
|
mapped_action = action_map.get(action.lower())
|
|
if not mapped_action:
|
|
return None
|
|
|
|
# Map entity types
|
|
entity_map = {
|
|
'project': 'project',
|
|
'task': 'task',
|
|
'time_entry': 'time_entry',
|
|
'invoice': 'invoice',
|
|
'client': 'client',
|
|
'user': 'user',
|
|
'comment': 'comment',
|
|
}
|
|
|
|
mapped_entity = entity_map.get(entity_type.lower())
|
|
if not mapped_entity:
|
|
return None
|
|
|
|
return f"{mapped_entity}.{mapped_action}"
|
|
|
|
@staticmethod
|
|
def build_payload_from_activity(activity, additional_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
"""Build webhook payload from Activity record
|
|
|
|
Args:
|
|
activity: Activity model instance
|
|
additional_data: Optional additional data to include
|
|
|
|
Returns:
|
|
dict: Webhook payload
|
|
"""
|
|
payload = {
|
|
'event_type': WebhookDispatcher.map_activity_to_event(activity.action, activity.entity_type),
|
|
'timestamp': activity.created_at.isoformat() if activity.created_at else None,
|
|
'user': {
|
|
'id': activity.user_id,
|
|
'username': activity.user.username if activity.user else None,
|
|
'display_name': activity.user.display_name if activity.user and hasattr(activity.user, 'display_name') else None,
|
|
},
|
|
'entity': {
|
|
'type': activity.entity_type,
|
|
'id': activity.entity_id,
|
|
'name': activity.entity_name,
|
|
},
|
|
'action': activity.action,
|
|
'description': activity.description,
|
|
}
|
|
|
|
# Add extra_data if available
|
|
if activity.extra_data:
|
|
payload['data'] = activity.extra_data
|
|
|
|
# Add additional data
|
|
if additional_data:
|
|
payload.update(additional_data)
|
|
|
|
return payload
|
|
|
|
@staticmethod
|
|
def on_activity_logged(activity):
|
|
"""Callback to be called when an activity is logged
|
|
|
|
This method should be called after Activity.log() to trigger webhooks
|
|
|
|
Args:
|
|
activity: Activity model instance that was just logged
|
|
"""
|
|
try:
|
|
# Map activity to webhook event type
|
|
event_type = WebhookDispatcher.map_activity_to_event(activity.action, activity.entity_type)
|
|
|
|
if not event_type:
|
|
# Event type not mappable, skip
|
|
return
|
|
|
|
# Build payload
|
|
payload = WebhookDispatcher.build_payload_from_activity(activity)
|
|
|
|
# Dispatch webhook
|
|
WebhookDispatcher.dispatch_event(
|
|
event_type=event_type,
|
|
payload=payload,
|
|
event_id=f"activity_{activity.id}",
|
|
user_id=activity.user_id
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing activity for webhook: {e}", exc_info=True)
|
|
|