Skip to content

Commit 9813c74

Browse files
committed
Support for generic webhooks
1 parent ad1abd1 commit 9813c74

File tree

12 files changed

+560
-128
lines changed

12 files changed

+560
-128
lines changed

readthedocs/builds/tasks.py

+112-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
from datetime import datetime, timedelta
44
from io import BytesIO
55

6+
import requests
67
from celery import Task
78
from django.conf import settings
9+
from django.urls import reverse
10+
from django.utils.translation import ugettext_lazy as _
811

912
from readthedocs.api.v2.serializers import BuildSerializer
1013
from readthedocs.api.v2.utils import (
@@ -25,8 +28,7 @@
2528
from readthedocs.builds.models import Build, Version
2629
from readthedocs.builds.utils import memcache_lock
2730
from readthedocs.core.permissions import AdminPermission
28-
from readthedocs.core.utils import trigger_build
29-
from readthedocs.oauth.models import RemoteRepository
31+
from readthedocs.core.utils import send_email, trigger_build
3032
from readthedocs.oauth.notifications import GitBuildStatusFailureNotification
3133
from readthedocs.projects.constants import GITHUB_BRAND, GITLAB_BRAND
3234
from readthedocs.projects.models import Project
@@ -453,3 +455,111 @@ def send_build_status(build_pk, commit, status, link_to_build=False):
453455
build.project.slug
454456
)
455457
return False
458+
459+
460+
@app.task(queue='web')
461+
def send_build_notifications(version_pk, build_pk, email=False):
462+
version = Version.objects.get_object_or_log(pk=version_pk)
463+
464+
if not version or version.type == EXTERNAL:
465+
return
466+
467+
build = Build.objects.get(pk=build_pk)
468+
469+
for hook in version.project.webhook_notifications.all():
470+
webhook_notification(version, build, hook.url)
471+
472+
if email:
473+
for email_address in version.project.emailhook_notifications.all().values_list(
474+
'email',
475+
flat=True,
476+
):
477+
email_notification(version, build, email_address)
478+
479+
480+
def email_notification(version, build, email):
481+
"""
482+
Send email notifications for build failure.
483+
484+
:param version: :py:class:`Version` instance that failed
485+
:param build: :py:class:`Build` instance that failed
486+
:param email: Email recipient address
487+
"""
488+
# We send only what we need from the Django model objects here to avoid
489+
# serialization problems in the ``readthedocs.core.tasks.send_email_task``
490+
context = {
491+
'version': {
492+
'verbose_name': version.verbose_name,
493+
},
494+
'project': {
495+
'name': version.project.name,
496+
},
497+
'build': {
498+
'pk': build.pk,
499+
'error': build.error,
500+
},
501+
'build_url': 'https://{}{}'.format(
502+
settings.PRODUCTION_DOMAIN,
503+
build.get_absolute_url(),
504+
),
505+
'unsub_url': 'https://{}{}'.format(
506+
settings.PRODUCTION_DOMAIN,
507+
reverse('projects_notifications', args=[version.project.slug]),
508+
),
509+
}
510+
511+
if build.commit:
512+
title = _(
513+
'Failed: {project[name]} ({commit})',
514+
).format(commit=build.commit[:8], **context)
515+
else:
516+
title = _('Failed: {project[name]} ({version[verbose_name]})').format(
517+
**context
518+
)
519+
520+
log.debug(
521+
'Sending email notification. project=%s version=%s build=%s',
522+
version.project.slug, version.slug, build.id,
523+
)
524+
send_email(
525+
email,
526+
title,
527+
template='projects/email/build_failed.txt',
528+
template_html='projects/email/build_failed.html',
529+
context=context,
530+
)
531+
532+
533+
def webhook_notification(version, build, hook_url):
534+
"""
535+
Send webhook notification for project webhook.
536+
537+
:param version: Version instance to send hook for
538+
:param build: Build instance that failed
539+
:param hook_url: Hook URL to send to
540+
"""
541+
project = version.project
542+
543+
data = json.dumps({
544+
'name': project.name,
545+
'slug': project.slug,
546+
'build': {
547+
'id': build.id,
548+
'commit': build.commit,
549+
'state': build.state,
550+
'success': build.success,
551+
'date': build.date.strftime('%Y-%m-%d %H:%M:%S'),
552+
},
553+
})
554+
try:
555+
log.debug(
556+
'Sending webhook notification. project=%s version=%s build=%s',
557+
version.project.slug, version.slug, build.pk,
558+
)
559+
requests.post(
560+
hook_url,
561+
data=data,
562+
headers={'content-type': 'application/json'}
563+
)
564+
except Exception:
565+
log.exception('Failed to POST on webhook url: url=%s', hook_url)

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+
]

readthedocs/projects/models.py

+95-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Project models."""
22
import fnmatch
3+
import hashlib
4+
import hmac
35
import logging
46
import os
57
import re
@@ -10,10 +12,12 @@
1012
from django.conf import settings
1113
from django.conf.urls import include
1214
from django.contrib.auth.models import User
15+
from django.contrib.contenttypes.fields import GenericRelation
1316
from django.core.validators import MaxValueValidator, MinValueValidator
1417
from django.db import models
1518
from django.db.models import Prefetch
1619
from django.urls import re_path, reverse
20+
from django.utils.crypto import get_random_string
1721
from django.utils.functional import cached_property
1822
from django.utils.translation import ugettext_lazy as _
1923
from django.views import defaults
@@ -1465,14 +1469,103 @@ def __str__(self):
14651469
return self.email
14661470

14671471

1468-
class WebHook(Notification):
1472+
class WebHookEvent(models.Model):
1473+
1474+
BUILD_TRIGGERED = 'build:triggered'
1475+
BUILD_PASSED = 'build:passed'
1476+
BUILD_FAILED = 'build:failed'
1477+
1478+
EVENTS = (
1479+
(BUILD_TRIGGERED, _('Build triggered')),
1480+
(BUILD_PASSED, _('Build passed')),
1481+
(BUILD_FAILED, _('Build failed')),
1482+
)
1483+
1484+
name = models.CharField(
1485+
max_length=256,
1486+
unique=True,
1487+
choices=EVENTS,
1488+
)
1489+
1490+
def __str__(self):
1491+
return self.name
1492+
1493+
1494+
class WebHook(Notification, TimeStampedModel):
1495+
14691496
url = models.URLField(
1497+
_('URL'),
14701498
max_length=600,
14711499
help_text=_('URL to send the webhook to'),
14721500
)
1501+
secret = models.CharField(
1502+
help_text=_('Secret used to sign the payload of the webhook'),
1503+
max_length=255,
1504+
blank=True,
1505+
null=True,
1506+
)
1507+
events = models.ManyToManyField(
1508+
WebHookEvent,
1509+
related_name='webhooks',
1510+
help_text=_('Events to subscribe'),
1511+
)
1512+
payload = models.TextField(
1513+
_('JSON payload'),
1514+
help_text=_(
1515+
'JSON payload to send to the webhook. '
1516+
'Check the docs for available substitutions.',
1517+
),
1518+
blank=True,
1519+
null=True,
1520+
)
1521+
exchanges = GenericRelation(
1522+
'integrations.HttpExchange',
1523+
related_query_name='webhook',
1524+
)
1525+
1526+
SUBSTITUTIONS = (
1527+
('${event}', _('Event that triggered the request')),
1528+
('${build.id}', _('Build ID')),
1529+
('${build.commit}', _('Commit being built')),
1530+
('${project.slug}', _('Project slug')),
1531+
('${project.name}', _('Project name')),
1532+
('${version.id}', _('Version ID')),
1533+
('${version.slug}', _('Version slug')),
1534+
)
1535+
1536+
def save(self, *args, **kwargs):
1537+
if not self.secret:
1538+
self.secret = get_random_string(length=32)
1539+
super().save(*args, **kwargs)
1540+
1541+
def get_payload(self, event, build):
1542+
project = build.project
1543+
version = build.version
1544+
substitutions = {
1545+
'${event}': event,
1546+
'${build.id}': build.id,
1547+
'${build.commit}': build.commit,
1548+
'${project.slug}': project.slug,
1549+
'${project.name}': project.name,
1550+
'${version.slug}': version.slug,
1551+
}
1552+
payload = self.payload
1553+
# Small protection for DDoS.
1554+
max_substitutions = 9
1555+
for substitution, value in substitutions.items():
1556+
payload = payload.replace(substitution, value, max_substitutions)
1557+
return payload
1558+
1559+
def sign_payload(self, payload):
1560+
digest = hmac.new(
1561+
self.secret.encode(),
1562+
msg=payload.encode(),
1563+
digestmod=hashlib.sha1,
1564+
)
1565+
return digest.hexdigest()
14731566

14741567
def __str__(self):
1475-
return self.url
1568+
return f'{self.project.slug} {self.url}'
14761569

14771570

14781571
class Domain(TimeStampedModel, models.Model):

0 commit comments

Comments
 (0)