Files
TimeTracker/app/models/webhook.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

285 lines
11 KiB
Python

"""Webhook models for enabling integrations"""
import hashlib
import hmac
import json
import secrets
from datetime import datetime
from app import db
from app.utils.timezone import now_in_app_timezone
class Webhook(db.Model):
"""Webhook configuration for sending events to external systems"""
__tablename__ = "webhooks"
id = db.Column(db.Integer, primary_key=True)
# Basic information
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Webhook URL and configuration
url = db.Column(db.String(500), nullable=False)
secret = db.Column(db.String(128), nullable=True) # Secret for HMAC signature
# Event subscriptions (JSON array of event types)
# Examples: ['project.created', 'time_entry.started', 'invoice.paid']
events = db.Column(db.JSON, nullable=False, default=list)
# HTTP configuration
http_method = db.Column(db.String(10), default="POST", nullable=False) # POST, PUT, PATCH
content_type = db.Column(db.String(50), default="application/json", nullable=False)
headers = db.Column(db.JSON, nullable=True) # Custom headers as JSON object
# Status and ownership
is_active = db.Column(db.Boolean, default=True, nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
user = db.relationship("User", backref="webhooks")
# Retry configuration
max_retries = db.Column(db.Integer, default=3, nullable=False)
retry_delay_seconds = db.Column(db.Integer, default=60, nullable=False) # Delay between retries
# Timeout configuration
timeout_seconds = db.Column(db.Integer, default=30, nullable=False)
# Statistics
total_deliveries = db.Column(db.Integer, default=0, nullable=False)
successful_deliveries = db.Column(db.Integer, default=0, nullable=False)
failed_deliveries = db.Column(db.Integer, default=0, nullable=False)
last_delivery_at = db.Column(db.DateTime, nullable=True)
last_success_at = db.Column(db.DateTime, nullable=True)
last_failure_at = db.Column(db.DateTime, nullable=True)
# Timestamps
created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False)
updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False)
# Indexes (user_id and is_active already have index=True on their columns;
# only created_at needs an explicit index here)
__table_args__ = (db.Index("ix_webhooks_created_at", "created_at"),)
def __repr__(self):
return f"<Webhook {self.name} ({self.url})>"
@staticmethod
def generate_secret():
"""Generate a secure random secret for webhook signing"""
return secrets.token_urlsafe(32)
def set_secret(self, secret=None):
"""Set or generate a webhook secret"""
if secret is None:
secret = self.generate_secret()
self.secret = secret
def verify_signature(self, payload, signature):
"""Verify HMAC signature of webhook payload
Args:
payload: The webhook payload (string or bytes)
signature: The signature header value
Returns:
bool: True if signature is valid
"""
if not self.secret:
return False
if isinstance(payload, str):
payload = payload.encode("utf-8")
expected_signature = hmac.new(self.secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
# Support both 'sha256=...' and plain hex formats
if signature.startswith("sha256="):
signature = signature[7:]
return hmac.compare_digest(expected_signature, signature)
def generate_signature(self, payload):
"""Generate HMAC signature for webhook payload
Args:
payload: The webhook payload (string or bytes)
Returns:
str: HMAC signature in format 'sha256=...'
"""
if not self.secret:
return None
if isinstance(payload, str):
payload = payload.encode("utf-8")
signature = hmac.new(self.secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
return f"sha256={signature}"
def subscribes_to(self, event_type):
"""Check if webhook subscribes to a specific event type
Args:
event_type: Event type string (e.g., 'project.created')
Returns:
bool: True if webhook subscribes to this event
"""
if not self.events:
return False
return event_type in self.events or "*" in self.events
def to_dict(self, include_secret=False):
"""Convert to dictionary for API responses"""
data = {
"id": self.id,
"name": self.name,
"description": self.description,
"url": self.url,
"events": self.events or [],
"http_method": self.http_method,
"content_type": self.content_type,
"headers": self.headers or {},
"is_active": self.is_active,
"user_id": self.user_id,
"max_retries": self.max_retries,
"retry_delay_seconds": self.retry_delay_seconds,
"timeout_seconds": self.timeout_seconds,
"total_deliveries": self.total_deliveries,
"successful_deliveries": self.successful_deliveries,
"failed_deliveries": self.failed_deliveries,
"last_delivery_at": self.last_delivery_at.isoformat() if self.last_delivery_at else None,
"last_success_at": self.last_success_at.isoformat() if self.last_success_at else None,
"last_failure_at": self.last_failure_at.isoformat() if self.last_failure_at else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_secret:
data["secret"] = self.secret
return data
class WebhookDelivery(db.Model):
"""Track individual webhook delivery attempts"""
__tablename__ = "webhook_deliveries"
id = db.Column(db.Integer, primary_key=True)
# Webhook reference
webhook_id = db.Column(db.Integer, db.ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False, index=True)
webhook = db.relationship("Webhook", backref="deliveries")
# Event information
event_type = db.Column(db.String(100), nullable=False, index=True)
event_id = db.Column(db.String(100), nullable=True) # Unique ID for this event instance
# Payload
payload = db.Column(db.Text, nullable=False) # JSON-encoded payload
payload_hash = db.Column(db.String(64), nullable=True) # SHA256 hash for deduplication
# Delivery status
status = db.Column(
db.String(20), nullable=False, default="pending", index=True
) # pending, success, failed, retrying
attempt_number = db.Column(db.Integer, default=1, nullable=False)
# HTTP response
response_status_code = db.Column(db.Integer, nullable=True)
response_body = db.Column(db.Text, nullable=True)
response_headers = db.Column(db.JSON, nullable=True)
# Error information
error_message = db.Column(db.Text, nullable=True)
error_type = db.Column(db.String(100), nullable=True) # timeout, connection_error, http_error, etc.
# Timing
started_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False)
completed_at = db.Column(db.DateTime, nullable=True)
duration_ms = db.Column(db.Integer, nullable=True) # Duration in milliseconds
# Retry information
next_retry_at = db.Column(db.DateTime, nullable=True, index=True)
retry_count = db.Column(db.Integer, default=0, nullable=False)
# Indexes (webhook_id, event_type, status, next_retry_at already have index=True;
# only started_at needs an explicit index here)
__table_args__ = (db.Index("ix_webhook_deliveries_started_at", "started_at"),)
def __repr__(self):
return f"<WebhookDelivery {self.webhook_id} {self.event_type} {self.status}>"
@staticmethod
def hash_payload(payload):
"""Generate hash of payload for deduplication"""
if isinstance(payload, str):
payload = payload.encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def mark_success(self, status_code, response_body=None, response_headers=None, duration_ms=None):
"""Mark delivery as successful"""
self.status = "success"
self.response_status_code = status_code
self.response_body = response_body
self.response_headers = response_headers
self.completed_at = now_in_app_timezone()
if duration_ms is not None:
self.duration_ms = duration_ms
# Update webhook statistics
if self.webhook:
self.webhook.total_deliveries += 1
self.webhook.successful_deliveries += 1
self.webhook.last_delivery_at = self.completed_at
self.webhook.last_success_at = self.completed_at
def mark_failed(
self, error_message, error_type=None, response_status_code=None, response_body=None, duration_ms=None
):
"""Mark delivery as failed"""
self.status = "failed"
self.error_message = error_message
self.error_type = error_type
self.response_status_code = response_status_code
self.response_body = response_body
self.completed_at = now_in_app_timezone()
if duration_ms is not None:
self.duration_ms = duration_ms
# Update webhook statistics
if self.webhook:
self.webhook.total_deliveries += 1
self.webhook.failed_deliveries += 1
self.webhook.last_delivery_at = self.completed_at
self.webhook.last_failure_at = self.completed_at
def mark_retrying(self, next_retry_at):
"""Mark delivery as retrying and schedule next attempt"""
self.status = "retrying"
self.next_retry_at = next_retry_at
self.retry_count += 1
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
"id": self.id,
"webhook_id": self.webhook_id,
"event_type": self.event_type,
"event_id": self.event_id,
"status": self.status,
"attempt_number": self.attempt_number,
"response_status_code": self.response_status_code,
"error_message": self.error_message,
"error_type": self.error_type,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"duration_ms": self.duration_ms,
"retry_count": self.retry_count,
"next_retry_at": self.next_retry_at.isoformat() if self.next_retry_at else None,
}