Skip to content

Commit 57858b5

Browse files
committed
Support for generic webhooks
1 parent ad1abd1 commit 57858b5

File tree

14 files changed

+653
-143
lines changed

14 files changed

+653
-143
lines changed

readthedocs/builds/tasks.py

+154-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
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

913
from readthedocs.api.v2.serializers import BuildSerializer
1014
from readthedocs.api.v2.utils import (
@@ -25,11 +29,11 @@
2529
from readthedocs.builds.models import Build, Version
2630
from readthedocs.builds.utils import memcache_lock
2731
from readthedocs.core.permissions import AdminPermission
28-
from readthedocs.core.utils import trigger_build
29-
from readthedocs.oauth.models import RemoteRepository
32+
from readthedocs.core.utils import send_email, trigger_build
33+
from readthedocs.integrations.models import HttpExchange
3034
from readthedocs.oauth.notifications import GitBuildStatusFailureNotification
3135
from readthedocs.projects.constants import GITHUB_BRAND, GITLAB_BRAND
32-
from readthedocs.projects.models import Project
36+
from readthedocs.projects.models import Project, WebHookEvent
3337
from readthedocs.storage import build_commands_storage
3438
from readthedocs.worker import app
3539

@@ -453,3 +457,150 @@ def send_build_status(build_pk, commit, status, link_to_build=False):
453457
build.project.slug
454458
)
455459
return False
460+
461+
462+
@app.task(queue='web')
463+
def send_build_notifications(version_pk, build_pk, event):
464+
version = Version.objects.get_object_or_log(pk=version_pk)
465+
466+
if not version or version.type == EXTERNAL:
467+
return
468+
469+
build = Build.objects.get(pk=build_pk)
470+
471+
sender = NotificationSender(
472+
version=version,
473+
build=build,
474+
event=event,
475+
)
476+
sender.send()
477+
478+
class NotificationSender:
479+
480+
def __init__(self, version, build, event):
481+
self.version = version
482+
self.build = build
483+
self.project = version.project
484+
self.event = event
485+
486+
def send(self):
487+
if self.event == WebHookEvent.BUILD_FAILED:
488+
email_addresses = (
489+
self.project.emailhook_notifications.all()
490+
.values_list('email', flat=True)
491+
)
492+
for email in email_addresses:
493+
try:
494+
self.send_email(email)
495+
except Exception:
496+
log.exception(
497+
'Failed to send email notification. email=%s',
498+
email,
499+
)
500+
501+
old_webhooks = []
502+
if self.event in [WebHookEvent.BUILD_FAILED, WebHookEvent.BUILD_PASSED]:
503+
old_webhooks = (
504+
self.project.webhook_notifications
505+
.filter(events__isnull=True)
506+
)
507+
508+
new_webhooks = (
509+
self.project.webhook_notifications
510+
.filter(events__name=self.event)
511+
)
512+
for webhook in itertools.chain(old_webhooks, new_webhooks):
513+
try:
514+
self.send_webhook(webhook)
515+
except Exception:
516+
log.exception(
517+
'Failed to send webhook. id=%s',
518+
webhook.id,
519+
)
520+
521+
def send_email(self, email):
522+
"""Send email notifications for build failure."""
523+
# We send only what we need from the Django model objects here to avoid
524+
# serialization problems in the ``readthedocs.core.tasks.send_email_task``
525+
context = {
526+
'version': {
527+
'verbose_name': self.version.verbose_name,
528+
},
529+
'project': {
530+
'name': self.project.name,
531+
},
532+
'build': {
533+
'pk': self.build.pk,
534+
'error': self.build.error,
535+
},
536+
'build_url': 'https://{}{}'.format(
537+
settings.PRODUCTION_DOMAIN,
538+
self.build.get_absolute_url(),
539+
),
540+
'unsub_url': 'https://{}{}'.format(
541+
settings.PRODUCTION_DOMAIN,
542+
reverse('projects_notifications', args=[self.project.slug]),
543+
),
544+
}
545+
546+
if self.build.commit:
547+
title = _(
548+
'Failed: {project[name]} ({commit})',
549+
).format(commit=self.build.commit[:8], **context)
550+
else:
551+
title = _('Failed: {project[name]} ({version[verbose_name]})').format(
552+
**context
553+
)
554+
555+
log.debug(
556+
'Sending email notification. project=%s version=%s build=%s',
557+
self.project.slug, self.version.slug, self.build.id,
558+
)
559+
send_email(
560+
email,
561+
title,
562+
template='projects/email/build_failed.txt',
563+
template_html='projects/email/build_failed.html',
564+
context=context,
565+
)
566+
567+
def send_webhook(self, webhook):
568+
"""Send webhook notification for project webhook."""
569+
payload = webhook.get_payload(
570+
version=self.version,
571+
build=self.build,
572+
event=self.event,
573+
)
574+
if not payload:
575+
payload = json.dumps({
576+
'name': self.project.name,
577+
'slug': self.project.slug,
578+
'build': {
579+
'id': self.build.id,
580+
'commit': self.build.commit,
581+
'state': self.build.state,
582+
'success': self.build.success,
583+
'date': self.build.date.strftime('%Y-%m-%d %H:%M:%S'),
584+
},
585+
})
586+
587+
headers = {'content-type': 'application/json'}
588+
if webhook.secret:
589+
headers['X-Hub-Signature'] = webhook.sign_payload(payload)
590+
591+
try:
592+
log.debug(
593+
'Sending webhook notification. project=%s version=%s build=%s',
594+
self.project.slug, self.version.slug, self.build.pk,
595+
)
596+
response = requests.post(
597+
webhook.url,
598+
data=payload,
599+
headers=headers,
600+
)
601+
HttpExchange.objects.from_requests_exchange(
602+
response=response,
603+
related_object=webhook,
604+
)
605+
except Exception:
606+
log.exception('Failed to POST on webhook url: url=%s', webhook.url)

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

+13-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,19 @@ 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+
request = response.request
99+
obj = self.create(
100+
related_object=related_object,
101+
request_headers=request.headers or {},
102+
request_body=request.body or '',
103+
status_code=response.status_code,
104+
response_headers=response.headers,
105+
response_body=response.text,
106+
)
107+
self.delete_limit(related_object)
108+
return obj
109+
99110
def delete_limit(self, related_object, limit=10):
100111
if isinstance(related_object, Integration):
101112
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

+45
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
@@ -463,6 +464,50 @@ class Meta:
463464
fields = ['url']
464465

465466

467+
class GenericWebHookForm(forms.ModelForm):
468+
469+
project = forms.CharField(widget=forms.HiddenInput(), required=False)
470+
471+
class Meta:
472+
model = WebHook
473+
fields = ['project', 'url', 'events', 'payload', 'secret']
474+
widgets = {
475+
'events': forms.CheckboxSelectMultiple,
476+
}
477+
478+
def __init__(self, *args, **kwargs):
479+
self.project = kwargs.pop('project', None)
480+
super().__init__(*args, **kwargs)
481+
482+
if self.instance and self.instance.pk:
483+
# Show secret in the detail form, but as readonly.
484+
self.fields['secret'].disabled = True
485+
else:
486+
# Don't show the secret in the creation form.
487+
del self.fields['secret']
488+
self.fields['payload'].initial = json.dumps({
489+
'event': '${event}',
490+
'name': '${project.name}',
491+
'slug': '${project.slug}',
492+
'version': '${version.slug}',
493+
'commit': '${build.id}',
494+
'commit': '${build.commit}',
495+
}, indent=2)
496+
497+
def clean_project(self):
498+
return self.project
499+
500+
def clean_payload(self):
501+
"""Check if the payload is a valid json object and format it."""
502+
payload = self.cleaned_data['payload']
503+
try:
504+
payload = json.loads(payload)
505+
payload = json.dumps(payload, indent=2)
506+
except Exception:
507+
raise forms.ValidationError(_('The payload must be a valid JSON object'))
508+
return payload
509+
510+
466511
class TranslationBaseForm(forms.Form):
467512

468513
"""Project translation form."""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Generated by Django 2.2.24 on 2021-09-22 21:02
2+
3+
from django.db import migrations, models
4+
import django.utils.timezone
5+
import django_extensions.db.fields
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('projects', '0081_add_another_header'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='WebHookEvent',
17+
fields=[
18+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('name', models.CharField(choices=[('build:triggered', 'Build triggered'), ('build:passed', 'Build passed'), ('build:failed', 'Build failed')], max_length=256, unique=True)),
20+
],
21+
),
22+
migrations.AddField(
23+
model_name='webhook',
24+
name='created',
25+
field=django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='created'),
26+
preserve_default=False,
27+
),
28+
migrations.AddField(
29+
model_name='webhook',
30+
name='modified',
31+
field=django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified'),
32+
),
33+
migrations.AddField(
34+
model_name='webhook',
35+
name='payload',
36+
field=models.TextField(blank=True, help_text='JSON payload to send to the webhook. Check the docs for available substitutions.', null=True, verbose_name='JSON payload'),
37+
),
38+
migrations.AddField(
39+
model_name='webhook',
40+
name='secret',
41+
field=models.CharField(blank=True, help_text='Secret used to sign the payload of the webhook', max_length=255, null=True),
42+
),
43+
migrations.AlterField(
44+
model_name='webhook',
45+
name='url',
46+
field=models.URLField(help_text='URL to send the webhook to', max_length=600, verbose_name='URL'),
47+
),
48+
migrations.AddField(
49+
model_name='webhook',
50+
name='events',
51+
field=models.ManyToManyField(help_text='Events to subscribe', related_name='webhooks', to='projects.WebHookEvent'),
52+
),
53+
]

0 commit comments

Comments
 (0)