Files
app-service/backend/app/crud/webhook.py
matteoscrugli 8c4a555b88 Add comprehensive backend features and mobile UI improvements
Backend:
- Add 2FA authentication with TOTP support
- Add API keys management system
- Add audit logging for security events
- Add file upload/management system
- Add notifications system with preferences
- Add session management
- Add webhooks integration
- Add analytics endpoints
- Add export functionality
- Add password policy enforcement
- Add new database migrations for core tables

Frontend:
- Add module position system (top/bottom sidebar sections)
- Add search and notifications module configuration tabs
- Add mobile logo replacing hamburger menu
- Center page title absolutely when no tabs present
- Align sidebar footer toggles with navigation items
- Add lighter icon color in dark theme for mobile
- Add API keys management page
- Add notifications page with context
- Add admin analytics and audit logs pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 22:27:32 +01:00

346 lines
10 KiB
Python

"""CRUD operations for webhooks."""
import json
import secrets
import hashlib
import hmac
import httpx
from datetime import datetime, timedelta
from typing import Optional, List, Any
from sqlalchemy.orm import Session
from app.models.webhook import Webhook, WebhookDelivery
from app.schemas.webhook import WebhookCreate, WebhookUpdate
class CRUDWebhook:
"""CRUD operations for webhooks."""
def get(self, db: Session, id: str) -> Optional[Webhook]:
"""Get a webhook by ID."""
return db.query(Webhook).filter(Webhook.id == id).first()
def get_multi(
self,
db: Session,
*,
skip: int = 0,
limit: int = 100,
is_active: Optional[bool] = None
) -> List[Webhook]:
"""Get multiple webhooks."""
query = db.query(Webhook)
if is_active is not None:
query = query.filter(Webhook.is_active == is_active)
return query.order_by(Webhook.created_at.desc()).offset(skip).limit(limit).all()
def get_by_event(self, db: Session, event_type: str) -> List[Webhook]:
"""Get all active webhooks that subscribe to an event type."""
webhooks = db.query(Webhook).filter(Webhook.is_active == True).all()
matching = []
for webhook in webhooks:
events = json.loads(webhook.events) if webhook.events else []
if "*" in events or event_type in events:
matching.append(webhook)
return matching
def create(
self,
db: Session,
*,
obj_in: WebhookCreate,
created_by: Optional[str] = None
) -> Webhook:
"""Create a new webhook with a generated secret."""
# Generate a secret for signature verification
secret = secrets.token_hex(32)
db_obj = Webhook(
name=obj_in.name,
url=obj_in.url,
secret=secret,
events=json.dumps(obj_in.events),
is_active=obj_in.is_active,
retry_count=obj_in.retry_count,
timeout_seconds=obj_in.timeout_seconds,
created_by=created_by
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
*,
db_obj: Webhook,
obj_in: WebhookUpdate
) -> Webhook:
"""Update a webhook."""
update_data = obj_in.model_dump(exclude_unset=True)
if "events" in update_data:
update_data["events"] = json.dumps(update_data["events"])
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, *, id: str) -> Optional[Webhook]:
"""Delete a webhook."""
obj = db.query(Webhook).filter(Webhook.id == id).first()
if obj:
db.delete(obj)
db.commit()
return obj
def regenerate_secret(self, db: Session, *, db_obj: Webhook) -> Webhook:
"""Regenerate the webhook secret."""
db_obj.secret = secrets.token_hex(32)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def count(self, db: Session) -> int:
"""Count total webhooks."""
return db.query(Webhook).count()
class CRUDWebhookDelivery:
"""CRUD operations for webhook deliveries."""
def get(self, db: Session, id: str) -> Optional[WebhookDelivery]:
"""Get a delivery by ID."""
return db.query(WebhookDelivery).filter(WebhookDelivery.id == id).first()
def get_by_webhook(
self,
db: Session,
*,
webhook_id: str,
skip: int = 0,
limit: int = 50
) -> List[WebhookDelivery]:
"""Get deliveries for a specific webhook."""
return (
db.query(WebhookDelivery)
.filter(WebhookDelivery.webhook_id == webhook_id)
.order_by(WebhookDelivery.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_pending_retries(self, db: Session) -> List[WebhookDelivery]:
"""Get deliveries that need to be retried."""
now = datetime.utcnow()
return (
db.query(WebhookDelivery)
.filter(
WebhookDelivery.status == "failed",
WebhookDelivery.next_retry_at <= now
)
.all()
)
def create(
self,
db: Session,
*,
webhook_id: str,
event_type: str,
payload: dict
) -> WebhookDelivery:
"""Create a new webhook delivery record."""
db_obj = WebhookDelivery(
webhook_id=webhook_id,
event_type=event_type,
payload=json.dumps(payload),
status="pending"
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update_status(
self,
db: Session,
*,
db_obj: WebhookDelivery,
status: str,
status_code: Optional[int] = None,
response_body: Optional[str] = None,
error_message: Optional[str] = None,
schedule_retry: bool = False,
max_retries: int = 3
) -> WebhookDelivery:
"""Update delivery status."""
db_obj.status = status
db_obj.status_code = status_code
db_obj.response_body = response_body[:1000] if response_body else None
db_obj.error_message = error_message
db_obj.attempt_count += 1
if status == "success":
db_obj.delivered_at = datetime.utcnow()
db_obj.next_retry_at = None
elif status == "failed" and schedule_retry and db_obj.attempt_count < max_retries:
# Exponential backoff: 1min, 5min, 30min
delays = [60, 300, 1800]
delay = delays[min(db_obj.attempt_count - 1, len(delays) - 1)]
db_obj.next_retry_at = datetime.utcnow() + timedelta(seconds=delay)
else:
db_obj.next_retry_at = None
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
class WebhookService:
"""Service for triggering and delivering webhooks."""
def __init__(self):
self.webhook_crud = CRUDWebhook()
self.delivery_crud = CRUDWebhookDelivery()
def generate_signature(self, payload: str, secret: str) -> str:
"""Generate HMAC-SHA256 signature for payload."""
return hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
async def trigger_event(
self,
db: Session,
event_type: str,
payload: dict
) -> List[WebhookDelivery]:
"""Trigger webhooks for an event."""
webhooks = self.webhook_crud.get_by_event(db, event_type)
deliveries = []
for webhook in webhooks:
delivery = self.delivery_crud.create(
db,
webhook_id=webhook.id,
event_type=event_type,
payload=payload
)
deliveries.append(delivery)
# Attempt delivery
await self.deliver(db, webhook, delivery)
return deliveries
async def deliver(
self,
db: Session,
webhook: Webhook,
delivery: WebhookDelivery
) -> bool:
"""Deliver a webhook."""
payload_str = delivery.payload
signature = self.generate_signature(payload_str, webhook.secret)
headers = {
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-Event": delivery.event_type,
"X-Webhook-Delivery-Id": delivery.id
}
try:
async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client:
response = await client.post(
webhook.url,
content=payload_str,
headers=headers
)
if response.status_code >= 200 and response.status_code < 300:
self.delivery_crud.update_status(
db,
db_obj=delivery,
status="success",
status_code=response.status_code,
response_body=response.text
)
webhook.success_count += 1
webhook.last_triggered_at = datetime.utcnow()
db.add(webhook)
db.commit()
return True
else:
self.delivery_crud.update_status(
db,
db_obj=delivery,
status="failed",
status_code=response.status_code,
response_body=response.text,
error_message=f"HTTP {response.status_code}",
schedule_retry=True,
max_retries=webhook.retry_count
)
webhook.failure_count += 1
db.add(webhook)
db.commit()
return False
except Exception as e:
self.delivery_crud.update_status(
db,
db_obj=delivery,
status="failed",
error_message=str(e),
schedule_retry=True,
max_retries=webhook.retry_count
)
webhook.failure_count += 1
db.add(webhook)
db.commit()
return False
async def test_webhook(
self,
db: Session,
webhook: Webhook,
event_type: str = "test.ping",
payload: Optional[dict] = None
) -> WebhookDelivery:
"""Send a test delivery to a webhook."""
if payload is None:
payload = {
"event": event_type,
"timestamp": datetime.utcnow().isoformat(),
"test": True,
"message": "This is a test webhook delivery"
}
delivery = self.delivery_crud.create(
db,
webhook_id=webhook.id,
event_type=event_type,
payload=payload
)
await self.deliver(db, webhook, delivery)
return delivery
# Singleton instances
webhook = CRUDWebhook()
webhook_delivery = CRUDWebhookDelivery()
webhook_service = WebhookService()