Skip to content

Commit 25f8543

Browse files
committed
Support for generic webhooks
1 parent 658f825 commit 25f8543

23 files changed

+1094
-251
lines changed

readthedocs/__init__.py

-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# -*- coding: utf-8 -*-
2-
31
"""Read the Docs."""
42

53
import os.path

readthedocs/builds/tasks.py

+179-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import itertools
12
import json
23
import logging
34
from datetime import datetime, timedelta
45
from io import BytesIO
56

7+
import requests
68
from celery import Task
79
from django.conf import settings
10+
from django.urls import reverse
11+
from django.utils.translation import ugettext_lazy as _
812

13+
from readthedocs import __version__
914
from readthedocs.api.v2.serializers import BuildSerializer
1015
from readthedocs.api.v2.utils import (
1116
delete_versions_from_db,
@@ -25,11 +30,11 @@
2530
from readthedocs.builds.models import Build, Version
2631
from readthedocs.builds.utils import memcache_lock
2732
from readthedocs.core.permissions import AdminPermission
28-
from readthedocs.core.utils import trigger_build
29-
from readthedocs.oauth.models import RemoteRepository
33+
from readthedocs.core.utils import send_email, trigger_build
34+
from readthedocs.integrations.models import HttpExchange
3035
from readthedocs.oauth.notifications import GitBuildStatusFailureNotification
3136
from readthedocs.projects.constants import GITHUB_BRAND, GITLAB_BRAND
32-
from readthedocs.projects.models import Project
37+
from readthedocs.projects.models import Project, WebHookEvent
3338
from readthedocs.storage import build_commands_storage
3439
from readthedocs.worker import app
3540

@@ -453,3 +458,174 @@ def send_build_status(build_pk, commit, status, link_to_build=False):
453458
build.project.slug
454459
)
455460
return False
461+
462+
463+
@app.task(queue='web')
464+
def send_build_notifications(version_pk, build_pk, event):
465+
version = Version.objects.get_object_or_log(pk=version_pk)
466+
if not version or version.type == EXTERNAL:
467+
return
468+
469+
build = Build.objects.filter(pk=build_pk).first()
470+
if not build:
471+
return
472+
473+
sender = BuildNotificationSender(
474+
version=version,
475+
build=build,
476+
event=event,
477+
)
478+
sender.send()
479+
480+
481+
class BuildNotificationSender:
482+
483+
webhook_timeout = 2
484+
485+
def __init__(self, version, build, event):
486+
self.version = version
487+
self.build = build
488+
self.project = version.project
489+
self.event = event
490+
491+
def send(self):
492+
"""
493+
Send email and webhook notifications for `project`.
494+
495+
Email notifications are only send for build:failed events.
496+
Webhooks choose to what events they subscribe to.
497+
"""
498+
if self.event == WebHookEvent.BUILD_FAILED:
499+
email_addresses = (
500+
self.project.emailhook_notifications.all()
501+
.values_list('email', flat=True)
502+
)
503+
for email in email_addresses:
504+
try:
505+
self.send_email(email)
506+
except Exception:
507+
log.exception(
508+
'Failed to send email notification. '
509+
'email=%s project=%s version=%s build=%s',
510+
email, self.project.slug, self.version.slug, self.build.pk,
511+
)
512+
513+
webhooks = (
514+
self.project.webhook_notifications
515+
.filter(events__name=self.event)
516+
)
517+
for webhook in webhooks:
518+
try:
519+
self.send_webhook(webhook)
520+
except Exception:
521+
log.exception(
522+
'Failed to send webhook. webhook=%s project=%s version=%s build=%s',
523+
webhook.id, self.project.slug, self.version.slug, self.build.pk,
524+
)
525+
526+
def send_email(self, email):
527+
"""Send email notifications for build failures."""
528+
# We send only what we need from the Django model objects here to avoid
529+
# serialization problems in the ``readthedocs.core.tasks.send_email_task``
530+
context = {
531+
'version': {
532+
'verbose_name': self.version.verbose_name,
533+
},
534+
'project': {
535+
'name': self.project.name,
536+
},
537+
'build': {
538+
'pk': self.build.pk,
539+
'error': self.build.error,
540+
},
541+
'build_url': 'https://{}{}'.format(
542+
settings.PRODUCTION_DOMAIN,
543+
self.build.get_absolute_url(),
544+
),
545+
'unsub_url': 'https://{}{}'.format(
546+
settings.PRODUCTION_DOMAIN,
547+
reverse('projects_notifications', args=[self.project.slug]),
548+
),
549+
}
550+
551+
if self.build.commit:
552+
title = _('Failed: {project[name]} ({commit})').format(
553+
commit=self.build.commit[:8],
554+
**context,
555+
)
556+
else:
557+
title = _('Failed: {project[name]} ({version[verbose_name]})').format(
558+
**context
559+
)
560+
561+
log.info(
562+
'Sending email notification. email=%s project=%s version=%s build=%s',
563+
email, self.project.slug, self.version.slug, self.build.id,
564+
)
565+
send_email(
566+
email,
567+
title,
568+
template='projects/email/build_failed.txt',
569+
template_html='projects/email/build_failed.html',
570+
context=context,
571+
)
572+
573+
def send_webhook(self, webhook):
574+
"""
575+
Send webhook notification.
576+
577+
The payload is signed using HMAC-SHA1,
578+
for users to be able to verify the authenticity of the request.
579+
580+
Webhooks that don't have a payload,
581+
are from the old implementation, for those we keep sending the
582+
old default payload.
583+
584+
An HttpExchange object is created for each transaction.
585+
"""
586+
payload = webhook.get_payload(
587+
version=self.version,
588+
build=self.build,
589+
event=self.event,
590+
)
591+
if not payload:
592+
# Default payload from old webhooks.
593+
payload = json.dumps({
594+
'name': self.project.name,
595+
'slug': self.project.slug,
596+
'build': {
597+
'id': self.build.id,
598+
'commit': self.build.commit,
599+
'state': self.build.state,
600+
'success': self.build.success,
601+
'date': self.build.date.strftime('%Y-%m-%d %H:%M:%S'),
602+
},
603+
})
604+
605+
headers = {
606+
'content-type': 'application/json',
607+
'User-Agent': f'Read-the-Docs/{__version__}'
608+
}
609+
if webhook.secret:
610+
headers['X-Hub-Signature'] = webhook.sign_payload(payload)
611+
612+
try:
613+
log.info(
614+
'Sending webhook notification. webhook=%s project=%s version=%s build=%s',
615+
webhook.pk, self.project.slug, self.version.slug, self.build.pk,
616+
)
617+
response = requests.post(
618+
webhook.url,
619+
data=payload,
620+
headers=headers,
621+
timeout=self.webhook_timeout,
622+
)
623+
HttpExchange.objects.from_requests_exchange(
624+
response=response,
625+
related_object=webhook,
626+
)
627+
except Exception:
628+
log.exception(
629+
'Failed to POST to webhook url. webhook=%s url=%s',
630+
webhook.pk, webhook.url,
631+
)

readthedocs/core/utils/__init__.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ def prepare_build(
5656
"""
5757
# Avoid circular import
5858
from readthedocs.builds.models import Build
59-
from readthedocs.projects.models import Feature, Project
59+
from readthedocs.builds.tasks import send_build_notifications
60+
from readthedocs.projects.models import Feature, Project, WebHookEvent
6061
from readthedocs.projects.tasks import (
6162
send_external_build_status,
62-
send_notifications,
6363
update_docs_task,
6464
)
6565

@@ -123,8 +123,12 @@ def prepare_build(
123123
)
124124

125125
if build and version.type != EXTERNAL:
126-
# Send Webhook notification for build triggered.
127-
send_notifications.delay(version.pk, build_pk=build.pk, email=False)
126+
# Send notifications for build triggered.
127+
send_build_notifications.delay(
128+
version_pk=version.pk,
129+
build_pk=build.pk,
130+
event=WebHookEvent.BUILD_TRIGGERED,
131+
)
128132

129133
options['priority'] = CELERY_HIGH
130134
if project.main_language_project:

readthedocs/integrations/models.py

+19-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# -*- coding: utf-8 -*-
2-
31
"""Integration models for external services."""
42

53
import json
@@ -96,6 +94,25 @@ def from_exchange(self, req, resp, related_object, payload=None):
9694
self.delete_limit(related_object)
9795
return obj
9896

97+
def from_requests_exchange(self, response, related_object):
98+
"""
99+
Create an exchange object from a requests' response.
100+
101+
:param response: The result from calling request.post() or similar.
102+
:param related_object: Object to use for generic relationship.
103+
"""
104+
request = response.request
105+
obj = self.create(
106+
related_object=related_object,
107+
request_headers=request.headers or {},
108+
request_body=request.body or '',
109+
status_code=response.status_code,
110+
response_headers=response.headers,
111+
response_body=response.text,
112+
)
113+
self.delete_limit(related_object)
114+
return obj
115+
99116
def delete_limit(self, related_object, limit=10):
100117
if isinstance(related_object, Integration):
101118
queryset = self.filter(integrations=related_object)

readthedocs/projects/admin.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@
1919
EmailHook,
2020
EnvironmentVariable,
2121
Feature,
22-
HTTPHeader,
2322
HTMLFile,
23+
HTTPHeader,
2424
ImportedFile,
2525
Project,
2626
ProjectRelationship,
2727
WebHook,
28+
WebHookEvent,
2829
)
2930
from .notifications import (
3031
DeprecatedBuildWebhookNotification,
@@ -410,4 +411,5 @@ class EnvironmentVariableAdmin(admin.ModelAdmin):
410411
admin.site.register(Feature, FeatureAdmin)
411412
admin.site.register(EmailHook)
412413
admin.site.register(WebHook)
414+
admin.site.register(WebHookEvent)
413415
admin.site.register(HTMLFile, ImportedFileAdmin)

readthedocs/projects/forms.py

+34-10
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Project forms."""
2+
import json
23
from random import choice
34
from re import fullmatch
45
from urllib.parse import urlparse
@@ -444,23 +445,46 @@ def save(self):
444445

445446
class WebHookForm(forms.ModelForm):
446447

447-
"""Project webhook form."""
448+
project = forms.CharField(widget=forms.HiddenInput(), required=False)
449+
450+
class Meta:
451+
model = WebHook
452+
fields = ['project', 'url', 'events', 'payload', 'secret']
453+
widgets = {
454+
'events': forms.CheckboxSelectMultiple,
455+
}
448456

449457
def __init__(self, *args, **kwargs):
450458
self.project = kwargs.pop('project', None)
451459
super().__init__(*args, **kwargs)
452460

453-
def save(self, commit=True):
454-
self.webhook = WebHook.objects.get_or_create(
455-
url=self.cleaned_data['url'],
456-
project=self.project,
457-
)[0]
458-
self.project.webhook_notifications.add(self.webhook)
461+
if self.instance and self.instance.pk:
462+
# Show secret in the detail form, but as readonly.
463+
self.fields['secret'].disabled = True
464+
else:
465+
# Don't show the secret in the creation form.
466+
self.fields.pop('secret')
467+
self.fields['payload'].initial = json.dumps({
468+
'event': '${event}',
469+
'name': '${project.name}',
470+
'slug': '${project.slug}',
471+
'version': '${version.slug}',
472+
'commit': '${build.commit}',
473+
'build': '${build.pk}',
474+
}, indent=2)
475+
476+
def clean_project(self):
459477
return self.project
460478

461-
class Meta:
462-
model = WebHook
463-
fields = ['url']
479+
def clean_payload(self):
480+
"""Check if the payload is a valid json object and format it."""
481+
payload = self.cleaned_data['payload']
482+
try:
483+
payload = json.loads(payload)
484+
payload = json.dumps(payload, indent=2)
485+
except Exception:
486+
raise forms.ValidationError(_('The payload must be a valid JSON object.'))
487+
return payload
464488

465489

466490
class TranslationBaseForm(forms.Form):

0 commit comments

Comments
 (0)