Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import (
|
||||
IntegrationViewSet,
|
||||
EventSubscriptionViewSet,
|
||||
DeliveryLogViewSet,
|
||||
)
|
||||
|
||||
app_name = 'connect'
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'integrations', IntegrationViewSet, basename='integration')
|
||||
router.register(r'subscriptions', EventSubscriptionViewSet, basename='subscription')
|
||||
router.register(r'logs', DeliveryLogViewSet, basename='delivery-log')
|
||||
|
||||
urlpatterns = []
|
||||
urlpatterns += router.urls
|
||||
@@ -0,0 +1,226 @@
|
||||
from rest_framework import viewsets, status, serializers
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from .models import Integration, EventSubscription, DeliveryLog
|
||||
from .serializers import (
|
||||
IntegrationSerializer,
|
||||
EventSubscriptionSerializer,
|
||||
DeliveryLogSerializer,
|
||||
)
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
permission_classes_by_action,
|
||||
IsAdmin,
|
||||
)
|
||||
from .handlers.webhook import WebhookHandler
|
||||
from .handlers.script import ScriptHandler
|
||||
|
||||
|
||||
class IntegrationViewSet(viewsets.ModelViewSet):
|
||||
queryset = Integration.objects.all()
|
||||
serializer_class = IntegrationSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
perms = permission_classes_by_action[self.action]
|
||||
except KeyError:
|
||||
# Respect view/action-specific permission_classes if provided; fallback to Authenticated
|
||||
perms = getattr(self, "permission_classes", [Authenticated])
|
||||
return [perm() for perm in perms]
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="subscriptions")
|
||||
def list_subscriptions(self, request, pk=None):
|
||||
qs = EventSubscription.objects.filter(integration_id=pk)
|
||||
serializer = EventSubscriptionSerializer(qs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
methods=["PUT"],
|
||||
description=(
|
||||
"Replace the integration's event subscriptions with the provided list. "
|
||||
"Accepts a JSON array of subscription objects. "
|
||||
"Existing subscriptions not in the list will be deleted. "
|
||||
"The 'payload_template' field is only relevant for webhook integrations."
|
||||
),
|
||||
request=inline_serializer(
|
||||
name="SetSubscriptionsRequest",
|
||||
fields={
|
||||
"event": serializers.CharField(help_text="Event name (e.g. 'channel_start')."),
|
||||
"enabled": serializers.BooleanField(required=False, default=True),
|
||||
"payload_template": serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="Custom payload template (webhook integrations only)."),
|
||||
},
|
||||
many=True,
|
||||
),
|
||||
responses={200: inline_serializer(
|
||||
name="SetSubscriptionsResponse",
|
||||
fields={
|
||||
"event": serializers.CharField(),
|
||||
"enabled": serializers.BooleanField(),
|
||||
"payload_template": serializers.CharField(allow_null=True),
|
||||
},
|
||||
many=True,
|
||||
)},
|
||||
)
|
||||
@action(detail=True, methods=["put"], url_path=r"subscriptions/set")
|
||||
def set_subscriptions(self, request, pk=None):
|
||||
"""
|
||||
Replace the integration's subscriptions with the provided list.
|
||||
Body format: [{"event": "channel_start", "enabled": true, "payload_template": "..."}, ...]
|
||||
Any existing subscriptions not in the list will be deleted; missing ones will be created/updated.
|
||||
"""
|
||||
try:
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
except Integration.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
data = request.data
|
||||
if not isinstance(data, list):
|
||||
return Response(
|
||||
{"detail": "Expected a list of subscriptions"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate incoming items using serializer (without integration field)
|
||||
# We'll attach the integration explicitly
|
||||
valid_events = set(evt for evt, _ in EventSubscription.EVENT_CHOICES)
|
||||
incoming = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
return Response(
|
||||
{"detail": "Each subscription must be an object"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
event = item.get("event")
|
||||
if event not in valid_events:
|
||||
return Response(
|
||||
{"detail": f"Invalid event: {event}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Only accept payload_template when the integration is a webhook
|
||||
payload_template = item.get("payload_template") if integration.type == "webhook" else None
|
||||
incoming.append(
|
||||
{
|
||||
"event": event,
|
||||
"enabled": bool(item.get("enabled", True)),
|
||||
"payload_template": payload_template,
|
||||
}
|
||||
)
|
||||
|
||||
incoming_events = {s["event"] for s in incoming}
|
||||
|
||||
# Delete subscriptions that are no longer present
|
||||
EventSubscription.objects.filter(integration=integration).exclude(
|
||||
event__in=incoming_events
|
||||
).delete()
|
||||
|
||||
# Upsert incoming subscriptions
|
||||
updated = []
|
||||
for sub in incoming:
|
||||
obj, _created = EventSubscription.objects.update_or_create(
|
||||
integration=integration,
|
||||
event=sub["event"],
|
||||
defaults={
|
||||
"enabled": sub["enabled"],
|
||||
"payload_template": sub.get("payload_template"),
|
||||
},
|
||||
)
|
||||
updated.append(obj)
|
||||
|
||||
serializer = EventSubscriptionSerializer(updated, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="test", permission_classes=[IsAdmin])
|
||||
def test(self, request, pk=None):
|
||||
"""
|
||||
Execute a saved integration (connect) with a dummy payload to verify configuration.
|
||||
"""
|
||||
try:
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
except Integration.DoesNotExist:
|
||||
return Response({"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Build a dummy payload similar to system events
|
||||
now = timezone.now().isoformat()
|
||||
dummy_payload = {
|
||||
"event": "test",
|
||||
"timestamp": now,
|
||||
"channel_name": "Test Channel",
|
||||
"stream_name": "Test Stream",
|
||||
"stream_url": "http://example.com/stream.m3u8",
|
||||
"channel_url": "http://example.com/stream.m3u8",
|
||||
"provider_name": "Test Provider",
|
||||
"profile_used": "Default",
|
||||
"test": True,
|
||||
}
|
||||
|
||||
# Choose handler based on saved type
|
||||
if integration.type == "webhook":
|
||||
handler = WebhookHandler(integration, None, dummy_payload)
|
||||
elif integration.type == "script":
|
||||
handler = ScriptHandler(integration, None, dummy_payload)
|
||||
else:
|
||||
return Response(
|
||||
{"success": False, "error": f"Unsupported integration type: {integration.type}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
result = handler.execute()
|
||||
return Response(
|
||||
{
|
||||
"success": bool(result.get("success")),
|
||||
"type": integration.type,
|
||||
"request_payload": dummy_payload,
|
||||
"result": result,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"type": integration.type,
|
||||
"request_payload": dummy_payload,
|
||||
"error": str(e),
|
||||
},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
|
||||
class EventSubscriptionViewSet(viewsets.ModelViewSet):
|
||||
queryset = EventSubscription.objects.all()
|
||||
serializer_class = EventSubscriptionSerializer
|
||||
|
||||
|
||||
class DeliveryLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = DeliveryLog.objects.all().order_by("-created_at")
|
||||
serializer_class = DeliveryLogSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
|
||||
# Support server-side pagination with page_size query param
|
||||
class ConnectLogsPagination(PageNumberPagination):
|
||||
page_size = 50
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 250
|
||||
|
||||
pagination_class = ConnectLogsPagination
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
|
||||
# Optional filters: integration id and type
|
||||
integration_id = self.request.query_params.get("integration")
|
||||
if integration_id:
|
||||
qs = qs.filter(subscription__integration_id=integration_id)
|
||||
|
||||
integration_type = self.request.query_params.get("type")
|
||||
if integration_type:
|
||||
qs = qs.filter(subscription__integration__type=integration_type)
|
||||
|
||||
return qs
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ConnectConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.connect'
|
||||
verbose_name = "Connect Integrations"
|
||||
label = 'dispatcharr_connect'
|
||||
@@ -0,0 +1,12 @@
|
||||
# connect/handlers/base.py
|
||||
import abc
|
||||
|
||||
class IntegrationHandler(abc.ABC):
|
||||
def __init__(self, integration, subscription, payload):
|
||||
self.integration = integration
|
||||
self.subscription = subscription
|
||||
self.payload = payload
|
||||
|
||||
@abc.abstractmethod
|
||||
def execute(self):
|
||||
pass
|
||||
@@ -0,0 +1,81 @@
|
||||
# connect/handlers/script.py
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from django.conf import settings
|
||||
from .base import IntegrationHandler
|
||||
|
||||
|
||||
def _is_path_allowed(real_path: str) -> bool:
|
||||
# Ensure path is within one of the allowed directories
|
||||
for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []):
|
||||
base_abs = os.path.abspath(base) + os.sep
|
||||
if real_path.startswith(base_abs):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ScriptHandler(IntegrationHandler):
|
||||
def execute(self):
|
||||
raw_path = self.integration.config.get("path")
|
||||
if not raw_path:
|
||||
raise ValueError("Missing 'path' in integration config")
|
||||
|
||||
# Resolve and validate path
|
||||
real_path = os.path.abspath(os.path.realpath(raw_path))
|
||||
|
||||
if not os.path.exists(real_path):
|
||||
raise FileNotFoundError(f"Script not found: {real_path}")
|
||||
|
||||
if not _is_path_allowed(real_path):
|
||||
raise PermissionError(
|
||||
f"Script path '{real_path}' not within allowed directories: "
|
||||
f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}"
|
||||
)
|
||||
|
||||
if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True):
|
||||
if not os.access(real_path, os.X_OK):
|
||||
raise PermissionError(f"Script is not executable: {real_path}")
|
||||
|
||||
if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True):
|
||||
st = os.stat(real_path)
|
||||
if st.st_mode & stat.S_IWOTH:
|
||||
raise PermissionError(
|
||||
f"Refusing to execute world-writable script: {real_path}"
|
||||
)
|
||||
|
||||
# Build a sanitized minimal environment; avoid inheriting secrets
|
||||
env = {
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
for key, value in (self.payload or {}).items():
|
||||
env_key = f"DISPATCHARR_{str(key).upper()}"
|
||||
env[env_key] = "" if value is None else str(value)
|
||||
|
||||
# Run with a timeout to prevent hanging scripts
|
||||
timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10)
|
||||
max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536)
|
||||
|
||||
result = subprocess.run(
|
||||
[real_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
cwd=os.path.dirname(real_path) or None,
|
||||
)
|
||||
|
||||
# Truncate outputs to avoid excessive memory/logging
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
if len(stdout) > max_out:
|
||||
stdout = stdout[:max_out] + "... [truncated]"
|
||||
if len(stderr) > max_out:
|
||||
stderr = stderr[:max_out] + "... [truncated]"
|
||||
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"success": result.returncode == 0,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# connect/handlers/webhook.py
|
||||
import requests, json, logging
|
||||
from .base import IntegrationHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WebhookHandler(IntegrationHandler):
|
||||
def execute(self):
|
||||
url = self.integration.config.get("url")
|
||||
headers = self.integration.config.get("headers", {})
|
||||
logger.info(self.payload)
|
||||
|
||||
try:
|
||||
parsed = json.loads(self.payload)
|
||||
headers["Content-Type"] = "application/json"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response = requests.post(url, data=self.payload, headers=headers, timeout=10)
|
||||
|
||||
return {"status_code": response.status_code, "body": response.text, "success": response.ok}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-27 21:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EventSubscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('event', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('movie_added', 'Movie Added'), ('series_added', 'Series Added'), ('download_complete', 'Download Complete')], max_length=100)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('payload_template', models.TextField(blank=True, help_text='Optional Jinja2/Django template for customizing payload', null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Integration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(choices=[('webhook', 'Webhook'), ('api', 'API'), ('script', 'Custom Script')], max_length=50)),
|
||||
('config', models.JSONField(default=dict)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeliveryLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)),
|
||||
('request_payload', models.JSONField(blank=True, default=dict)),
|
||||
('response_payload', models.JSONField(blank=True, default=dict)),
|
||||
('error_message', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='dispatcharr_connect.eventsubscription')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventsubscription',
|
||||
name='integration',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='dispatcharr_connect.integration'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-26 19:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_connect', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='eventsubscription',
|
||||
name='event',
|
||||
field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked')], max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
from django.db import models
|
||||
|
||||
SUPPORTED_EVENTS = {
|
||||
"channel_start": "Channel Started",
|
||||
"channel_stop": "Channel Stopped",
|
||||
"channel_reconnect": "Channel Reconnected",
|
||||
"channel_error": "Channel Error",
|
||||
"channel_failover": "Channel Failover",
|
||||
"stream_switch": "Stream Switch",
|
||||
"recording_start": "Recording Started",
|
||||
"recording_end": "Recording Ended",
|
||||
"epg_refresh": "EPG Refreshed",
|
||||
"m3u_refresh": "M3U Refreshed",
|
||||
"client_connect": "Client Connected",
|
||||
"client_disconnect": "Client Disconnected",
|
||||
"login_failed": "Login Failed",
|
||||
"epg_blocked": "EPG Blocked",
|
||||
"m3u_blocked": "M3U Blocked",
|
||||
}
|
||||
|
||||
class Integration(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
("webhook", "Webhook"),
|
||||
("api", "API"),
|
||||
("script", "Custom Script"),
|
||||
]
|
||||
name = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=50, choices=TYPE_CHOICES)
|
||||
config = models.JSONField(default=dict)
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class EventSubscription(models.Model):
|
||||
EVENT_CHOICES = list(SUPPORTED_EVENTS.items())
|
||||
event = models.CharField(max_length=100, choices=EVENT_CHOICES)
|
||||
integration = models.ForeignKey(Integration, on_delete=models.CASCADE, related_name="subscriptions")
|
||||
enabled = models.BooleanField(default=True)
|
||||
payload_template = models.TextField(blank=True, null=True, help_text="Optional Jinja2/Django template for customizing payload")
|
||||
|
||||
class DeliveryLog(models.Model):
|
||||
subscription = models.ForeignKey(EventSubscription, on_delete=models.CASCADE, related_name="logs")
|
||||
status = models.CharField(max_length=50, choices=[("success", "Success"), ("failed", "Failed")])
|
||||
request_payload = models.JSONField(default=dict, blank=True)
|
||||
response_payload = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -0,0 +1,68 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Integration, EventSubscription, DeliveryLog
|
||||
import os
|
||||
|
||||
|
||||
class EventSubscriptionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = EventSubscription
|
||||
fields = [
|
||||
"id",
|
||||
"event",
|
||||
"enabled",
|
||||
"payload_template",
|
||||
"integration",
|
||||
]
|
||||
|
||||
|
||||
class IntegrationSerializer(serializers.ModelSerializer):
|
||||
subscriptions = EventSubscriptionSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Integration
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"config",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"subscriptions",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
type = attrs.get("type") if "type" in attrs else getattr(self.instance, "type", None)
|
||||
config = attrs.get("config") if "config" in attrs else getattr(self.instance, "config", {})
|
||||
|
||||
if type == "script":
|
||||
path = (config or {}).get("path")
|
||||
if not path or not isinstance(path, str):
|
||||
raise serializers.ValidationError({"config": "Script config must include a 'path' string"})
|
||||
|
||||
real_path = os.path.abspath(os.path.realpath(path))
|
||||
if not os.path.exists(real_path):
|
||||
raise serializers.ValidationError({"config": f"Script path does not exist: {path}"})
|
||||
elif type == "webhook":
|
||||
url = (config or {}).get("url")
|
||||
if not url or not isinstance(url, str):
|
||||
raise serializers.ValidationError({"config": "Webhook config must include a 'url' string"})
|
||||
else:
|
||||
raise serializers.ValidationError({"type": "Unsupported integration type"})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class DeliveryLogSerializer(serializers.ModelSerializer):
|
||||
subscription = EventSubscriptionSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeliveryLog
|
||||
fields = [
|
||||
"id",
|
||||
"subscription",
|
||||
"status",
|
||||
"request_payload",
|
||||
"response_payload",
|
||||
"error_message",
|
||||
"created_at",
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
# connect/utils.py
|
||||
import logging, json
|
||||
from django.template import Template, Context
|
||||
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
|
||||
from .handlers.webhook import WebhookHandler
|
||||
from .handlers.script import ScriptHandler
|
||||
from apps.plugins.loader import PluginManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HANDLERS = {
|
||||
"webhook": WebhookHandler,
|
||||
"script": ScriptHandler,
|
||||
}
|
||||
|
||||
|
||||
def trigger_event(event_name, payload):
|
||||
if event_name not in SUPPORTED_EVENTS:
|
||||
logger.debug(f"Unsupported event '{event_name}' - skipping")
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}"
|
||||
)
|
||||
subscriptions = EventSubscription.objects.filter(
|
||||
event=event_name, enabled=True
|
||||
).select_related("integration")
|
||||
|
||||
count = subscriptions.count()
|
||||
logger.info(f"Found {count} connect subscription(s) for event '{event_name}'")
|
||||
|
||||
# First, fetch all subscriptions and trigger
|
||||
for sub in subscriptions:
|
||||
integration = sub.integration
|
||||
if not integration.enabled:
|
||||
logger.debug(
|
||||
f"Skipping disabled integration id={integration.id} name={integration.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
# apply optional payload template (only for webhook integrations)
|
||||
# If the rendered template is valid JSON, use that object as the payload.
|
||||
# Otherwise, pass the rendered string as-is.
|
||||
final_payload = payload
|
||||
if integration.type == 'webhook' and sub.payload_template:
|
||||
try:
|
||||
template = Template(sub.payload_template)
|
||||
final_payload = template.render(Context(payload)).strip()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Payload template render failed for subscription id={sub.id}: {e}"
|
||||
)
|
||||
final_payload = payload
|
||||
|
||||
handler_cls = HANDLERS.get(integration.type)
|
||||
if not handler_cls:
|
||||
DeliveryLog.objects.create(
|
||||
subscription=sub,
|
||||
status="failed",
|
||||
request_payload=final_payload,
|
||||
error_message=f"No handler for integration type '{integration.type}'",
|
||||
)
|
||||
logger.error(
|
||||
f"No handler for integration type '{integration.type}' (integration id={integration.id})"
|
||||
)
|
||||
continue
|
||||
|
||||
handler = handler_cls(integration, sub, final_payload)
|
||||
logger.debug(
|
||||
f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = handler.execute()
|
||||
DeliveryLog.objects.create(
|
||||
subscription=sub,
|
||||
status="success" if result.get("success") else "failed",
|
||||
request_payload=final_payload,
|
||||
response_payload=result,
|
||||
)
|
||||
logger.info(
|
||||
f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'"
|
||||
)
|
||||
except Exception as e:
|
||||
DeliveryLog.objects.create(
|
||||
subscription=sub,
|
||||
status="failed",
|
||||
request_payload=final_payload,
|
||||
error_message=str(e),
|
||||
)
|
||||
logger.error(
|
||||
f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}"
|
||||
)
|
||||
|
||||
pm = PluginManager.get()
|
||||
pm.discover_plugins(sync_db=False, use_cache=True)
|
||||
plugins = pm.list_plugins()
|
||||
|
||||
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'")
|
||||
for plugin in plugins:
|
||||
if not plugin["enabled"]:
|
||||
logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")
|
||||
continue
|
||||
|
||||
logger.debug(json.dumps(plugin))
|
||||
for action in plugin["actions"]:
|
||||
if "events" in action and event_name in action["events"]:
|
||||
key = plugin["key"]
|
||||
params = {"event": event_name, "payload": payload}
|
||||
action_name = action.get("label") or action.get("id")
|
||||
action_id = action.get("id")
|
||||
logger.debug(
|
||||
f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}"
|
||||
)
|
||||
if action_id:
|
||||
pm.run_action(key, action_id, params)
|
||||
Reference in New Issue
Block a user