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>
This commit is contained in:
345
backend/app/crud/webhook.py
Normal file
345
backend/app/crud/webhook.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user