Merge pull request #1146 from timthelion/webhooks-2

Webhooks
This commit is contained in:
Christopher Broderick 2023-12-05 21:53:15 +00:00 committed by GitHub
commit c138047128
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 308 additions and 5 deletions

View File

@ -1,6 +1,3 @@
Integrating django-helpdesk into your application
=================================================
Ticket submission with embeded iframe
-------------------------------------

View File

@ -17,7 +17,8 @@ Contents
spam
custom_fields
api
integration
webhooks
iframe_submission
teams
license

12
docs/webhooks.rst Normal file
View File

@ -0,0 +1,12 @@
Registering webhooks to be notified of helpesk events
-----------------------------------------------------
You can register webhooks to allow third party apps to be notified of helpdesk events. Webhooks can be registered in one of two ways:
1. Setting the following environement variables to a comma separated list of URLs; ``HELPDESK_NEW_TICKET_WEBHOOK_URLS``& ``HELPDESK_FOLLOWUP_WEBHOOK_URLS``.
2. Adding getter functions to your ``settings.py``. These should return a list of strings (urls); ``HELPDESK_GET_NEW_TICKET_WEBHOOK_URLS`` & ``HELPDESK_GET_FOLLOWUP_WEBHOOK_URLS``.
3. You can optionally set ``HELPDESK_WEBHOOK_TIMEOUT`` which defaults to 3 seconds. Warning, however, webhook requests are sent out sychronously on ticket update. If your webhook handling server is too slow, you should fix this rather than causing helpdesk freezes by messing with this variable.
Once these URLs are configured, a serialized copy of the ticket object will be posted to each of these URLs each time a ticket is created or followed up on respectively.

View File

@ -8,3 +8,6 @@ class HelpdeskConfig(AppConfig):
# see:
# https://docs.djangoproject.com/en/3.2/ref/applications/#django.apps.AppConfig.default_auto_field
default_auto_field = 'django.db.models.AutoField'
def ready(self):
from . import webhooks # noqa: F401

View File

@ -21,6 +21,7 @@ from email.message import EmailMessage, MIMEPart
from email.utils import getaddresses
from email_reply_parser import EmailReplyParser
from helpdesk import settings
from helpdesk import webhooks
from helpdesk.exceptions import DeleteIgnoredTicketException, IgnoreTicketException
from helpdesk.lib import process_attachments, safe_template_context
from helpdesk.models import FollowUp, IgnoreEmail, Queue, Ticket
@ -616,6 +617,8 @@ def create_object_from_email_message(message, ticket_id, payload, files, logger)
"Message seems to be auto-reply, not sending any emails back to the sender")
else:
send_info_email(message_id, f, ticket, context, queue, new)
if not new:
webhooks.notify_followup_webhooks(f)
return ticket

View File

@ -7,6 +7,7 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import os
import re
import warnings
@ -277,3 +278,19 @@ HELPDESK_IMAP_DEBUG_LEVEL = getattr(settings, 'HELPDESK_IMAP_DEBUG_LEVEL', 0)
# Attachment directories should be created with permission 755 (rwxr-xr-x)
# Override it in your own Django settings.py
HELPDESK_ATTACHMENT_DIR_PERMS = int(getattr(settings, 'HELPDESK_ATTACHMENT_DIR_PERMS', "755"), 8)
def get_followup_webhook_urls():
urls = os.environ.get('HELPDESK_FOLLOWUP_WEBHOOK_URLS', None)
if urls:
return re.split(r'[\s],[\s]', urls)
HELPDESK_GET_FOLLOWUP_WEBHOOK_URLS = getattr(settings, 'HELPDESK_GET_FOLLOWUP_WEBHOOK_URLS', get_followup_webhook_urls)
def get_new_ticket_webhook_urls():
urls = os.environ.get('HELPDESK_NEW_TICKET_WEBHOOK_URLS', None)
if urls:
return urls.split(',')
HELPDESK_GET_NEW_TICKET_WEBHOOK_URLS = getattr(settings, 'HELPDESK_GET_NEW_TICKET_WEBHOOK_URLS', get_new_ticket_webhook_urls)
HELPDESK_WEBHOOK_TIMEOUT = getattr(settings, 'HELPDESK_WEBHOOK_TIMEOUT', 3)

View File

@ -0,0 +1,210 @@
from django.contrib.auth.models import User
from helpdesk.models import Queue
from rest_framework.status import (
HTTP_201_CREATED
)
from rest_framework.test import APITestCase
import json
import os
import requests
import logging
# Set up a test weberver listeining on localhost:8123 for webhooks
import http.server
import threading
from http import HTTPStatus
class WebhookRequestHandler(http.server.BaseHTTPRequestHandler):
server: "WebhookServer"
def do_POST(self):
content_length = int(self.headers['Content-Length'])
body = self.rfile.read(content_length)
self.server.requests.append({
'path': self.path,
'headers': self.headers,
'body': body
})
if self.path == '/new-ticket':
self.server.handled_new_ticket_requests.append(json.loads(body.decode('utf-8')))
if self.path == '/new-ticket-1':
self.server.handled_new_ticket_requests_1.append(json.loads(body.decode('utf-8')))
elif self.path == '/followup':
self.server.handled_follow_up_requests.append(json.loads(body.decode('utf-8')))
elif self.path == '/followup-1':
self.server.handled_follow_up_requests_1.append(json.loads(body.decode('utf-8')))
self.send_response(HTTPStatus.OK)
self.end_headers()
def do_GET(self):
if not self.path == '/get-past-requests':
self.send_response(HTTPStatus.NOT_FOUND)
self.end_headers()
return
self.send_response(HTTPStatus.OK)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({
'new_ticket_requests': self.server.handled_new_ticket_requests,
'new_ticket_requests_1': self.server.handled_new_ticket_requests_1,
'follow_up_requests': self.server.handled_follow_up_requests,
'follow_up_requests_1': self.server.handled_follow_up_requests_1
}).encode('utf-8'))
class WebhookServer(http.server.HTTPServer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.requests = []
self.handled_new_ticket_requests = []
self.handled_new_ticket_requests_1 = []
self.handled_follow_up_requests = []
self.handled_follow_up_requests_1 = []
def start(self):
self.thread = threading.Thread(target=self.serve_forever)
self.thread.daemon = True # Set as a daemon so it will be killed once the main thread is dead
self.thread.start()
def stop(self):
self.shutdown()
self.server_close()
self.thread.join()
class WebhookTest(APITestCase):
@classmethod
def setUpTestData(cls):
cls.queue = Queue.objects.create(
title='Test Queue',
slug='test-queue',
)
def setUp(self):
staff_user = User.objects.create_user(username='test', is_staff=True)
self.client.force_authenticate(staff_user)
def test_test_server(self):
server = WebhookServer(('localhost', 8123), WebhookRequestHandler)
server.start()
requests.post('http://localhost:8123/new-ticket', json={
"foo": "bar"})
handled_webhook_requests = requests.get('http://localhost:8123/get-past-requests').json()
self.assertEqual(handled_webhook_requests['new_ticket_requests'][-1]["foo"], "bar")
server.stop()
def test_create_ticket_and_followup_via_api(self):
server = WebhookServer(('localhost', 8124), WebhookRequestHandler)
os.environ['HELPDESK_NEW_TICKET_WEBHOOK_URLS'] = 'http://localhost:8124/new-ticket, http://localhost:8124/new-ticket-1'
os.environ["HELPDESK_FOLLOWUP_WEBHOOK_URLS"] = 'http://localhost:8124/followup , http://localhost:8124/followup-1'
server.start()
response = self.client.post('/api/tickets/', {
'queue': self.queue.id,
'title': 'Test title',
'description': 'Test description\nMulti lines',
'submitter_email': 'test@mail.com',
'priority': 4
})
self.assertEqual(response.status_code, HTTP_201_CREATED)
handled_webhook_requests = requests.get('http://localhost:8124/get-past-requests')
handled_webhook_requests = handled_webhook_requests.json()
self.assertTrue(len(handled_webhook_requests['new_ticket_requests']) == 1)
self.assertTrue(len(handled_webhook_requests['new_ticket_requests_1']) == 1)
self.assertEqual(len(handled_webhook_requests['follow_up_requests']), 0)
self.assertEqual(handled_webhook_requests['new_ticket_requests'][-1]["ticket"]["title"], "Test title")
self.assertEqual(handled_webhook_requests['new_ticket_requests_1'][-1]["ticket"]["title"], "Test title")
self.assertEqual(handled_webhook_requests['new_ticket_requests'][-1]["ticket"]["description"], "Test description\nMulti lines")
response = self.client.post('/api/followups/', {
'ticket': handled_webhook_requests['new_ticket_requests'][-1]["ticket"]["id"],
"comment": "Test comment",
})
self.assertEqual(response.status_code, HTTP_201_CREATED)
handled_webhook_requests = requests.get('http://localhost:8124/get-past-requests')
handled_webhook_requests = handled_webhook_requests.json()
self.assertEqual(len(handled_webhook_requests['follow_up_requests']), 1)
self.assertEqual(len(handled_webhook_requests['follow_up_requests_1']), 1)
self.assertEqual(handled_webhook_requests['follow_up_requests'][-1]["ticket"]["followup_set"][-1]["comment"], "Test comment")
self.assertEqual(handled_webhook_requests['follow_up_requests_1'][-1]["ticket"]["followup_set"][-1]["comment"], "Test comment")
server.stop()
def test_create_ticket_and_followup_via_email(self):
from .. import email
server = WebhookServer(('localhost', 8125), WebhookRequestHandler)
os.environ['HELPDESK_NEW_TICKET_WEBHOOK_URLS'] = 'http://localhost:8125/new-ticket'
os.environ["HELPDESK_FOLLOWUP_WEBHOOK_URLS"] = 'http://localhost:8125/followup'
server.start()
class MockMessage(dict):
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def get_all(self, key, default=None):
return self.__dict__.get(key, default)
payload = {
'body': "hello",
'full_body': "hello",
'subject': "Test subject",
'queue': self.queue,
'sender_email': "user@example.com",
'priority': "1",
'files': [],
}
message = {
"To": ["info@example.com"],
"Cc": [],
"Message-Id": "random1",
"In-Reply-To": "",
}
email.create_object_from_email_message(
message=MockMessage(**message),
ticket_id=None,
payload=payload,
files=[],
logger=logging.getLogger('helpdesk'),
)
handled_webhook_requests = requests.get('http://localhost:8125/get-past-requests')
handled_webhook_requests = handled_webhook_requests.json()
self.assertEqual(len(handled_webhook_requests['new_ticket_requests']), 1)
self.assertEqual(len(handled_webhook_requests['follow_up_requests']), 0)
ticket_id = handled_webhook_requests['new_ticket_requests'][-1]["ticket"]['id']
from .. import models
ticket = models.Ticket.objects.get(id=ticket_id)
payload = {
'body': "hello",
'full_body': "hello",
'subject': f"[test-queue-{ticket_id}] Test subject",
'queue': self.queue,
'sender_email': "user@example.com",
'priority': "1",
'files': [],
}
message = {
"To": ["info@example.com"],
"Cc": [],
"Message-Id": "random",
"In-Reply-To": "123",
}
email.create_object_from_email_message(
message=MockMessage(**message),
ticket_id=ticket_id,
payload=payload,
files=[],
logger=logging.getLogger('helpdesk'),
)
handled_webhook_requests = requests.get('http://localhost:8125/get-past-requests')
handled_webhook_requests = handled_webhook_requests.json()
self.assertEqual(len(handled_webhook_requests['follow_up_requests']), 1)
self.assertEqual(handled_webhook_requests['follow_up_requests'][-1]["ticket"]["followup_set"][-1]["comment"], "hello")
self.assertEqual(handled_webhook_requests['follow_up_requests'][-1]["ticket"]["id"], ticket_id)
server.stop()

View File

@ -386,6 +386,9 @@ def update_ticket(
))
ticket.save()
from helpdesk.webhooks import notify_followup_webhooks
notify_followup_webhooks(f)
# auto subscribe user if enabled
add_staff_subscription(user, ticket)
return f

View File

@ -210,7 +210,7 @@ class ViewTicket(TemplateView):
queue, ticket_id = Ticket.queue_and_id_from_query(ticket_req)
if request.user.is_authenticated and request.user.email == email:
ticket = Ticket.objects.get(id=ticket_id, submitter_email__iexact=email)
if hasattr(settings, 'HELPDESK_VIEW_A_TICKET_PUBLIC') and settings.HELPDESK_VIEW_A_TICKET_PUBLIC:
elif hasattr(settings, 'HELPDESK_VIEW_A_TICKET_PUBLIC') and settings.HELPDESK_VIEW_A_TICKET_PUBLIC:
ticket = Ticket.objects.get(id=ticket_id, submitter_email__iexact=email)
else:
ticket = Ticket.objects.get(id=ticket_id, submitter_email__iexact=email, secret_key__iexact=key)

56
helpdesk/webhooks.py Normal file
View File

@ -0,0 +1,56 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from . import settings
import requests
import requests.exceptions
import logging
from .models import Ticket
from .serializers import TicketSerializer
logger = logging.getLogger(__name__)
def notify_followup_webhooks(followup):
urls = settings.HELPDESK_GET_FOLLOWUP_WEBHOOK_URLS()
if not urls:
return
# Serialize the ticket associated with the followup
ticket = followup.ticket
serialized_ticket = TicketSerializer(ticket).data
# Prepare the data to send
data = {
'ticket': serialized_ticket,
'queue_slug': ticket.queue.slug,
'followup_id': followup.id
}
for url in urls:
try:
requests.post(url, json=data, timeout=settings.HELPDESK_WEBHOOK_TIMEOUT)
except requests.exceptions.Timeout:
logger.error('Timeout while sending followup webhook to %s', url)
@receiver(post_save, sender=Ticket)
def ticket_post_save(sender, instance, created, **kwargs):
if not created:
return
urls = settings.HELPDESK_GET_NEW_TICKET_WEBHOOK_URLS()
if not urls:
return
# Serialize the ticket
serialized_ticket = TicketSerializer(instance).data
# Prepare the data to send
data = {
'ticket': serialized_ticket,
'queue_slug': instance.queue.slug
}
for url in urls:
try:
requests.post(url, json=data, timeout=settings.HELPDESK_WEBHOOK_TIMEOUT)
except requests.exceptions.Timeout:
logger.error('Timeout while sending new ticket webhook to %s', url)

View File

@ -13,3 +13,4 @@ django-model-utils
django-cleanup
oauthlib
requests_oauthlib
requests