From 12cd38b5840fce303adf3e4f6ff2a0ab20baff34 Mon Sep 17 00:00:00 2001
From: Santos Gallegos
Date: Wed, 22 Sep 2021 16:06:43 -0500
Subject: [PATCH 01/42] Support for generic webhooks
---
readthedocs/__init__.py | 2 -
readthedocs/builds/tasks.py | 182 ++++++++++-
readthedocs/core/utils/__init__.py | 12 +-
readthedocs/integrations/models.py | 21 +-
readthedocs/projects/admin.py | 4 +-
readthedocs/projects/forms.py | 44 ++-
.../migrations/0082_init_generic_webhooks.py | 64 ++++
.../migrations/0083_create_webhook_events.py | 21 ++
.../0084_subscribe_old_webhooks_to_events.py | 29 ++
readthedocs/projects/models.py | 111 ++++++-
readthedocs/projects/tasks.py | 156 ++-------
readthedocs/projects/urls/private.py | 37 ++-
readthedocs/projects/views/private.py | 94 ++++--
readthedocs/rtd_tests/base.py | 2 +-
.../tests/test_build_notifications.py | 302 +++++++++++++++---
.../rtd_tests/tests/test_privacy_urls.py | 28 +-
.../rtd_tests/tests/test_project_forms.py | 50 ++-
.../rtd_tests/tests/test_project_views.py | 2 -
.../templates/projects/project_edit_base.html | 3 +-
.../projects/project_notifications.html | 27 +-
.../projects/webhook_exchange_detail.html | 39 +++
.../templates/projects/webhook_form.html | 70 ++++
.../templates/projects/webhook_list.html | 56 ++++
23 files changed, 1105 insertions(+), 251 deletions(-)
create mode 100644 readthedocs/projects/migrations/0082_init_generic_webhooks.py
create mode 100644 readthedocs/projects/migrations/0083_create_webhook_events.py
create mode 100644 readthedocs/projects/migrations/0084_subscribe_old_webhooks_to_events.py
create mode 100644 readthedocs/templates/projects/webhook_exchange_detail.html
create mode 100644 readthedocs/templates/projects/webhook_form.html
create mode 100644 readthedocs/templates/projects/webhook_list.html
diff --git a/readthedocs/__init__.py b/readthedocs/__init__.py
index 8f8c9ee7c80..198abfeea67 100644
--- a/readthedocs/__init__.py
+++ b/readthedocs/__init__.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
"""Read the Docs."""
import os.path
diff --git a/readthedocs/builds/tasks.py b/readthedocs/builds/tasks.py
index edc38c75ab7..ef6bcaf64d6 100644
--- a/readthedocs/builds/tasks.py
+++ b/readthedocs/builds/tasks.py
@@ -1,11 +1,16 @@
+import itertools
import json
import logging
from datetime import datetime, timedelta
from io import BytesIO
+import requests
from celery import Task
from django.conf import settings
+from django.urls import reverse
+from django.utils.translation import ugettext_lazy as _
+from readthedocs import __version__
from readthedocs.api.v2.serializers import BuildSerializer
from readthedocs.api.v2.utils import (
delete_versions_from_db,
@@ -25,11 +30,11 @@
from readthedocs.builds.models import Build, Version
from readthedocs.builds.utils import memcache_lock
from readthedocs.core.permissions import AdminPermission
-from readthedocs.core.utils import trigger_build
-from readthedocs.oauth.models import RemoteRepository
+from readthedocs.core.utils import send_email, trigger_build
+from readthedocs.integrations.models import HttpExchange
from readthedocs.oauth.notifications import GitBuildStatusFailureNotification
from readthedocs.projects.constants import GITHUB_BRAND, GITLAB_BRAND
-from readthedocs.projects.models import Project
+from readthedocs.projects.models import Project, WebHookEvent
from readthedocs.storage import build_commands_storage
from readthedocs.worker import app
@@ -453,3 +458,174 @@ def send_build_status(build_pk, commit, status, link_to_build=False):
build.project.slug
)
return False
+
+
+@app.task(queue='web')
+def send_build_notifications(version_pk, build_pk, event):
+ version = Version.objects.get_object_or_log(pk=version_pk)
+ if not version or version.type == EXTERNAL:
+ return
+
+ build = Build.objects.filter(pk=build_pk).first()
+ if not build:
+ return
+
+ sender = BuildNotificationSender(
+ version=version,
+ build=build,
+ event=event,
+ )
+ sender.send()
+
+
+class BuildNotificationSender:
+
+ webhook_timeout = 2
+
+ def __init__(self, version, build, event):
+ self.version = version
+ self.build = build
+ self.project = version.project
+ self.event = event
+
+ def send(self):
+ """
+ Send email and webhook notifications for `project`.
+
+ Email notifications are only send for build:failed events.
+ Webhooks choose to what events they subscribe to.
+ """
+ if self.event == WebHookEvent.BUILD_FAILED:
+ email_addresses = (
+ self.project.emailhook_notifications.all()
+ .values_list('email', flat=True)
+ )
+ for email in email_addresses:
+ try:
+ self.send_email(email)
+ except Exception:
+ log.exception(
+ 'Failed to send email notification. '
+ 'email=%s project=%s version=%s build=%s',
+ email, self.project.slug, self.version.slug, self.build.pk,
+ )
+
+ webhooks = (
+ self.project.webhook_notifications
+ .filter(events__name=self.event)
+ )
+ for webhook in webhooks:
+ try:
+ self.send_webhook(webhook)
+ except Exception:
+ log.exception(
+ 'Failed to send webhook. webhook=%s project=%s version=%s build=%s',
+ webhook.id, self.project.slug, self.version.slug, self.build.pk,
+ )
+
+ def send_email(self, email):
+ """Send email notifications for build failures."""
+ # We send only what we need from the Django model objects here to avoid
+ # serialization problems in the ``readthedocs.core.tasks.send_email_task``
+ context = {
+ 'version': {
+ 'verbose_name': self.version.verbose_name,
+ },
+ 'project': {
+ 'name': self.project.name,
+ },
+ 'build': {
+ 'pk': self.build.pk,
+ 'error': self.build.error,
+ },
+ 'build_url': 'https://{}{}'.format(
+ settings.PRODUCTION_DOMAIN,
+ self.build.get_absolute_url(),
+ ),
+ 'unsub_url': 'https://{}{}'.format(
+ settings.PRODUCTION_DOMAIN,
+ reverse('projects_notifications', args=[self.project.slug]),
+ ),
+ }
+
+ if self.build.commit:
+ title = _('Failed: {project[name]} ({commit})').format(
+ commit=self.build.commit[:8],
+ **context,
+ )
+ else:
+ title = _('Failed: {project[name]} ({version[verbose_name]})').format(
+ **context
+ )
+
+ log.info(
+ 'Sending email notification. email=%s project=%s version=%s build=%s',
+ email, self.project.slug, self.version.slug, self.build.id,
+ )
+ send_email(
+ email,
+ title,
+ template='projects/email/build_failed.txt',
+ template_html='projects/email/build_failed.html',
+ context=context,
+ )
+
+ def send_webhook(self, webhook):
+ """
+ Send webhook notification.
+
+ The payload is signed using HMAC-SHA1,
+ for users to be able to verify the authenticity of the request.
+
+ Webhooks that don't have a payload,
+ are from the old implementation, for those we keep sending the
+ old default payload.
+
+ An HttpExchange object is created for each transaction.
+ """
+ payload = webhook.get_payload(
+ version=self.version,
+ build=self.build,
+ event=self.event,
+ )
+ if not payload:
+ # Default payload from old webhooks.
+ payload = json.dumps({
+ 'name': self.project.name,
+ 'slug': self.project.slug,
+ 'build': {
+ 'id': self.build.id,
+ 'commit': self.build.commit,
+ 'state': self.build.state,
+ 'success': self.build.success,
+ 'date': self.build.date.strftime('%Y-%m-%d %H:%M:%S'),
+ },
+ })
+
+ headers = {
+ 'content-type': 'application/json',
+ 'User-Agent': f'Read-the-Docs/{__version__}'
+ }
+ if webhook.secret:
+ headers['X-Hub-Signature'] = webhook.sign_payload(payload)
+
+ try:
+ log.info(
+ 'Sending webhook notification. webhook=%s project=%s version=%s build=%s',
+ webhook.pk, self.project.slug, self.version.slug, self.build.pk,
+ )
+ response = requests.post(
+ webhook.url,
+ data=payload,
+ headers=headers,
+ timeout=self.webhook_timeout,
+ )
+ HttpExchange.objects.from_requests_exchange(
+ response=response,
+ related_object=webhook,
+ )
+ except Exception:
+ log.exception(
+ 'Failed to POST to webhook url. webhook=%s url=%s',
+ webhook.pk, webhook.url,
+ )
diff --git a/readthedocs/core/utils/__init__.py b/readthedocs/core/utils/__init__.py
index 094d99a3e28..6ef577877ed 100644
--- a/readthedocs/core/utils/__init__.py
+++ b/readthedocs/core/utils/__init__.py
@@ -56,10 +56,10 @@ def prepare_build(
"""
# Avoid circular import
from readthedocs.builds.models import Build
- from readthedocs.projects.models import Feature, Project
+ from readthedocs.builds.tasks import send_build_notifications
+ from readthedocs.projects.models import Feature, Project, WebHookEvent
from readthedocs.projects.tasks import (
send_external_build_status,
- send_notifications,
update_docs_task,
)
@@ -123,8 +123,12 @@ def prepare_build(
)
if build and version.type != EXTERNAL:
- # Send Webhook notification for build triggered.
- send_notifications.delay(version.pk, build_pk=build.pk, email=False)
+ # Send notifications for build triggered.
+ send_build_notifications.delay(
+ version_pk=version.pk,
+ build_pk=build.pk,
+ event=WebHookEvent.BUILD_TRIGGERED,
+ )
options['priority'] = CELERY_HIGH
if project.main_language_project:
diff --git a/readthedocs/integrations/models.py b/readthedocs/integrations/models.py
index f8daf557385..8ae5d9c43e3 100644
--- a/readthedocs/integrations/models.py
+++ b/readthedocs/integrations/models.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
"""Integration models for external services."""
import json
@@ -96,6 +94,25 @@ def from_exchange(self, req, resp, related_object, payload=None):
self.delete_limit(related_object)
return obj
+ def from_requests_exchange(self, response, related_object):
+ """
+ Create an exchange object from a requests' response.
+
+ :param response: The result from calling request.post() or similar.
+ :param related_object: Object to use for generic relationship.
+ """
+ request = response.request
+ obj = self.create(
+ related_object=related_object,
+ request_headers=request.headers or {},
+ request_body=request.body or '',
+ status_code=response.status_code,
+ response_headers=response.headers,
+ response_body=response.text,
+ )
+ self.delete_limit(related_object)
+ return obj
+
def delete_limit(self, related_object, limit=10):
if isinstance(related_object, Integration):
queryset = self.filter(integrations=related_object)
diff --git a/readthedocs/projects/admin.py b/readthedocs/projects/admin.py
index b7382c8c777..496ec0866a5 100644
--- a/readthedocs/projects/admin.py
+++ b/readthedocs/projects/admin.py
@@ -19,12 +19,13 @@
EmailHook,
EnvironmentVariable,
Feature,
- HTTPHeader,
HTMLFile,
+ HTTPHeader,
ImportedFile,
Project,
ProjectRelationship,
WebHook,
+ WebHookEvent,
)
from .notifications import (
DeprecatedBuildWebhookNotification,
@@ -410,4 +411,5 @@ class EnvironmentVariableAdmin(admin.ModelAdmin):
admin.site.register(Feature, FeatureAdmin)
admin.site.register(EmailHook)
admin.site.register(WebHook)
+admin.site.register(WebHookEvent)
admin.site.register(HTMLFile, ImportedFileAdmin)
diff --git a/readthedocs/projects/forms.py b/readthedocs/projects/forms.py
index d1f854d92b0..0994e09784a 100644
--- a/readthedocs/projects/forms.py
+++ b/readthedocs/projects/forms.py
@@ -1,4 +1,5 @@
"""Project forms."""
+import json
from random import choice
from re import fullmatch
from urllib.parse import urlparse
@@ -444,23 +445,46 @@ def save(self):
class WebHookForm(forms.ModelForm):
- """Project webhook form."""
+ project = forms.CharField(widget=forms.HiddenInput(), required=False)
+
+ class Meta:
+ model = WebHook
+ fields = ['project', 'url', 'events', 'payload', 'secret']
+ widgets = {
+ 'events': forms.CheckboxSelectMultiple,
+ }
def __init__(self, *args, **kwargs):
self.project = kwargs.pop('project', None)
super().__init__(*args, **kwargs)
- def save(self, commit=True):
- self.webhook = WebHook.objects.get_or_create(
- url=self.cleaned_data['url'],
- project=self.project,
- )[0]
- self.project.webhook_notifications.add(self.webhook)
+ if self.instance and self.instance.pk:
+ # Show secret in the detail form, but as readonly.
+ self.fields['secret'].disabled = True
+ else:
+ # Don't show the secret in the creation form.
+ self.fields.pop('secret')
+ self.fields['payload'].initial = json.dumps({
+ 'event': '${event}',
+ 'name': '${project.name}',
+ 'slug': '${project.slug}',
+ 'version': '${version.slug}',
+ 'commit': '${build.commit}',
+ 'build': '${build.pk}',
+ }, indent=2)
+
+ def clean_project(self):
return self.project
- class Meta:
- model = WebHook
- fields = ['url']
+ def clean_payload(self):
+ """Check if the payload is a valid json object and format it."""
+ payload = self.cleaned_data['payload']
+ try:
+ payload = json.loads(payload)
+ payload = json.dumps(payload, indent=2)
+ except Exception:
+ raise forms.ValidationError(_('The payload must be a valid JSON object.'))
+ return payload
class TranslationBaseForm(forms.Form):
diff --git a/readthedocs/projects/migrations/0082_init_generic_webhooks.py b/readthedocs/projects/migrations/0082_init_generic_webhooks.py
new file mode 100644
index 00000000000..5b442aaa4a2
--- /dev/null
+++ b/readthedocs/projects/migrations/0082_init_generic_webhooks.py
@@ -0,0 +1,64 @@
+# Generated by Django 2.2.24 on 2021-09-27 18:11
+
+from django.db import migrations, models
+import django.utils.timezone
+import django_extensions.db.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0081_add_another_header'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='WebHookEvent',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(choices=[('build:triggered', 'Build triggered'), ('build:passed', 'Build passed'), ('build:failed', 'Build failed')], max_length=256, unique=True)),
+ ],
+ ),
+ migrations.AddField(
+ model_name='emailhook',
+ name='created',
+ field=django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='created', null=True),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='emailhook',
+ name='modified',
+ field=django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified'),
+ ),
+ migrations.AddField(
+ model_name='webhook',
+ name='created',
+ field=django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='created', null=True),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='webhook',
+ name='modified',
+ field=django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified'),
+ ),
+ migrations.AddField(
+ model_name='webhook',
+ name='payload',
+ field=models.TextField(blank=True, help_text='JSON payload to send to the webhook. Check the docs for available substitutions.', max_length=25000, null=True, verbose_name='JSON payload'),
+ ),
+ migrations.AddField(
+ model_name='webhook',
+ name='secret',
+ field=models.CharField(blank=True, help_text='Secret used to sign the payload of the webhook', max_length=255, null=True),
+ ),
+ migrations.AlterField(
+ model_name='webhook',
+ name='url',
+ field=models.URLField(help_text='URL to send the webhook to', max_length=600, verbose_name='URL'),
+ ),
+ migrations.AddField(
+ model_name='webhook',
+ name='events',
+ field=models.ManyToManyField(help_text='Events to subscribe', related_name='webhooks', to='projects.WebHookEvent'),
+ ),
+ ]
diff --git a/readthedocs/projects/migrations/0083_create_webhook_events.py b/readthedocs/projects/migrations/0083_create_webhook_events.py
new file mode 100644
index 00000000000..7002a92abd3
--- /dev/null
+++ b/readthedocs/projects/migrations/0083_create_webhook_events.py
@@ -0,0 +1,21 @@
+# Generated by Django 2.2.24 on 2021-09-27 20:43
+
+from django.db import migrations
+
+
+def forwards_func(apps, schema_editor):
+ """Sets all null ranks to zero."""
+ WebHookEvent = apps.get_model('projects', 'WebHookEvent')
+ for event in ['build:triggered', 'build:failed', 'build:passed']:
+ WebHookEvent.objects.get_or_create(name=event)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0082_init_generic_webhooks'),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards_func),
+ ]
diff --git a/readthedocs/projects/migrations/0084_subscribe_old_webhooks_to_events.py b/readthedocs/projects/migrations/0084_subscribe_old_webhooks_to_events.py
new file mode 100644
index 00000000000..26a88a1488a
--- /dev/null
+++ b/readthedocs/projects/migrations/0084_subscribe_old_webhooks_to_events.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.2.24 on 2021-09-28 19:44
+
+from django.db import migrations
+
+
+def forwards_func(apps, schema_editor):
+ """Migrate old webhooks to subscribe to events instead."""
+ WebHook = apps.get_model('projects', 'WebHook')
+ WebHookEvent = apps.get_model('projects', 'WebHookEvent')
+ old_webhooks = WebHook.objects.filter(events__isnull=True)
+ default_events = [
+ WebHookEvent.objects.get(name='build:triggered'),
+ WebHookEvent.objects.get(name='build:passed'),
+ WebHookEvent.objects.get(name='build:failed'),
+ ]
+ for webhook in old_webhooks:
+ webhook.events.set(default_events)
+ webhook.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0083_create_webhook_events'),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards_func),
+ ]
diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py
index 52a79670fea..06d533a47f2 100644
--- a/readthedocs/projects/models.py
+++ b/readthedocs/projects/models.py
@@ -1,5 +1,7 @@
"""Project models."""
import fnmatch
+import hashlib
+import hmac
import logging
import os
import re
@@ -10,10 +12,12 @@
from django.conf import settings
from django.conf.urls import include
from django.contrib.auth.models import User
+from django.contrib.contenttypes.fields import GenericRelation
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Prefetch
from django.urls import re_path, reverse
+from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.views import defaults
@@ -1446,7 +1450,15 @@ def processed_json(self):
return self.get_processed_json()
-class Notification(models.Model):
+class Notification(TimeStampedModel):
+ # TODO: Overridden from TimeStampedModel just to allow null values,
+ # remove after deploy.
+ created = CreationDateTimeField(
+ _('created'),
+ null=True,
+ blank=True,
+ )
+
project = models.ForeignKey(
Project,
related_name='%(class)s_notifications',
@@ -1465,14 +1477,109 @@ def __str__(self):
return self.email
+class WebHookEvent(models.Model):
+
+ BUILD_TRIGGERED = 'build:triggered'
+ BUILD_PASSED = 'build:passed'
+ BUILD_FAILED = 'build:failed'
+
+ EVENTS = (
+ (BUILD_TRIGGERED, _('Build triggered')),
+ (BUILD_PASSED, _('Build passed')),
+ (BUILD_FAILED, _('Build failed')),
+ )
+
+ name = models.CharField(
+ max_length=256,
+ unique=True,
+ choices=EVENTS,
+ )
+
+ def __str__(self):
+ return self.name
+
+
class WebHook(Notification):
+
url = models.URLField(
+ _('URL'),
max_length=600,
help_text=_('URL to send the webhook to'),
)
+ secret = models.CharField(
+ help_text=_('Secret used to sign the payload of the webhook'),
+ max_length=255,
+ blank=True,
+ null=True,
+ )
+ events = models.ManyToManyField(
+ WebHookEvent,
+ related_name='webhooks',
+ help_text=_('Events to subscribe'),
+ )
+ payload = models.TextField(
+ _('JSON payload'),
+ help_text=_(
+ 'JSON payload to send to the webhook. '
+ 'Check the docs for available substitutions.',
+ ),
+ blank=True,
+ null=True,
+ max_length=25000,
+ )
+ exchanges = GenericRelation(
+ 'integrations.HttpExchange',
+ related_query_name='webhook',
+ )
+
+ def save(self, *args, **kwargs):
+ if not self.secret:
+ self.secret = get_random_string(length=32)
+ super().save(*args, **kwargs)
+
+ def get_payload(self, version, build, event):
+ """Get the final payload replacing all placeholders."""
+ if not self.payload:
+ return None
+
+ project = version.project
+ organization = project.organizations.first()
+
+ organization_name = ''
+ organization_slug = ''
+ if organization:
+ organization_slug = organization.slug
+ organization_name = organization.name
+
+ substitutions = {
+ '${event}': event,
+ '${build.id}': build.id,
+ '${build.commit}': build.commit or '',
+ '${organization.name}': organization_name,
+ '${organization.slug}': organization_slug,
+ '${project.slug}': project.slug,
+ '${project.name}': project.name,
+ '${version.slug}': version.slug,
+ '${version.name}': version.verbose_name,
+ }
+ payload = self.payload
+ # Small protection for DDoS.
+ max_substitutions = 99
+ for substitution, value in substitutions.items():
+ payload = payload.replace(substitution, str(value), max_substitutions)
+ return payload
+
+ def sign_payload(self, payload):
+ """Get the signature of `payload` using HMAC-SHA1 with the webhook secret."""
+ digest = hmac.new(
+ key=self.secret.encode(),
+ msg=payload.encode(),
+ digestmod=hashlib.sha1,
+ )
+ return digest.hexdigest()
def __str__(self):
- return self.url
+ return f'{self.project.slug} {self.url}'
class Domain(TimeStampedModel, models.Model):
diff --git a/readthedocs/projects/tasks.py b/readthedocs/projects/tasks.py
index e3a2c98ad25..5705c22a5fb 100644
--- a/readthedocs/projects/tasks.py
+++ b/readthedocs/projects/tasks.py
@@ -17,11 +17,9 @@
from collections import Counter, defaultdict
from fnmatch import fnmatch
-import requests
from celery.exceptions import SoftTimeLimitExceeded
from django.conf import settings
from django.db.models import Q
-from django.urls import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from slumber.exceptions import HttpClientError
@@ -43,7 +41,6 @@
from readthedocs.builds.models import APIVersion, Build, Version
from readthedocs.builds.signals import build_complete
from readthedocs.config import ConfigError
-from readthedocs.core.utils import send_email
from readthedocs.doc_builder.config import load_yaml_config
from readthedocs.doc_builder.environments import (
DockerBuildEnvironment,
@@ -60,7 +57,7 @@
)
from readthedocs.doc_builder.loader import get_builder_class
from readthedocs.doc_builder.python_environments import Conda, Virtualenv
-from readthedocs.projects.models import APIProject, Feature
+from readthedocs.projects.models import APIProject, Feature, WebHookEvent
from readthedocs.projects.signals import (
after_build,
before_build,
@@ -640,7 +637,11 @@ def run(
self.setup_env.update_build(BUILD_STATE_FINISHED)
# Send notifications for unhandled errors
- self.send_notifications(version_pk, build_pk, email=True)
+ self.send_notifications(
+ version_pk,
+ build_pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
return False
def run_setup(self, record=True):
@@ -715,7 +716,11 @@ def run_setup(self, record=True):
# triggered before the previous one has finished (e.g. two webhooks,
# one after the other)
if not isinstance(environment.failure, VersionLockedError):
- self.send_notifications(self.version.pk, self.build['id'], email=True)
+ self.send_notifications(
+ self.version.pk,
+ self.build['id'],
+ event=WebHookEvent.BUILD_FAILED,
+ )
return False
@@ -820,7 +825,11 @@ def run_build(self, record):
if self.build_env.failed:
# Send Webhook and email notification for build failure.
- self.send_notifications(self.version.pk, self.build['id'], email=True)
+ self.send_notifications(
+ self.version.pk,
+ self.build['id'],
+ event=WebHookEvent.BUILD_FAILED,
+ )
if self.commit:
send_external_build_status(
@@ -831,7 +840,11 @@ def run_build(self, record):
)
elif self.build_env.successful:
# Send Webhook notification for build success.
- self.send_notifications(self.version.pk, self.build['id'], email=False)
+ self.send_notifications(
+ self.version.pk,
+ self.build['id'],
+ event=WebHookEvent.BUILD_PASSED,
+ )
# Push cached environment on success for next build
self.push_cached_environment()
@@ -1296,12 +1309,16 @@ def build_docs_class(self, builder_class):
builder.move()
return success
- def send_notifications(self, version_pk, build_pk, email=False):
- """Send notifications on build failure."""
+ def send_notifications(self, version_pk, build_pk, event):
+ """Send notifications to all subscribers of `event`."""
# Try to infer the version type if we can
# before creating a task.
if not self.version or self.version.type != EXTERNAL:
- send_notifications.delay(version_pk, build_pk=build_pk, email=email)
+ build_tasks.send_build_notifications.delay(
+ version_pk=version_pk,
+ build_pk=build_pk,
+ event=event,
+ )
def is_type_sphinx(self):
"""Is documentation type Sphinx."""
@@ -1627,123 +1644,6 @@ def _sync_imported_files(version, build):
)
-@app.task(queue='web')
-def send_notifications(version_pk, build_pk, email=False):
- version = Version.objects.get_object_or_log(pk=version_pk)
-
- if not version or version.type == EXTERNAL:
- return
-
- build = Build.objects.get(pk=build_pk)
-
- for hook in version.project.webhook_notifications.all():
- webhook_notification(version, build, hook.url)
-
- if email:
- for email_address in version.project.emailhook_notifications.all().values_list(
- 'email',
- flat=True,
- ):
- email_notification(version, build, email_address)
-
-
-def email_notification(version, build, email):
- """
- Send email notifications for build failure.
-
- :param version: :py:class:`Version` instance that failed
- :param build: :py:class:`Build` instance that failed
- :param email: Email recipient address
- """
- log.debug(
- LOG_TEMPLATE,
- {
- 'project': version.project.slug,
- 'version': version.slug,
- 'msg': 'sending email to: %s' % email,
- }
- )
-
- # We send only what we need from the Django model objects here to avoid
- # serialization problems in the ``readthedocs.core.tasks.send_email_task``
- context = {
- 'version': {
- 'verbose_name': version.verbose_name,
- },
- 'project': {
- 'name': version.project.name,
- },
- 'build': {
- 'pk': build.pk,
- 'error': build.error,
- },
- 'build_url': 'https://{}{}'.format(
- settings.PRODUCTION_DOMAIN,
- build.get_absolute_url(),
- ),
- 'unsub_url': 'https://{}{}'.format(
- settings.PRODUCTION_DOMAIN,
- reverse('projects_notifications', args=[version.project.slug]),
- ),
- }
-
- if build.commit:
- title = _(
- 'Failed: {project[name]} ({commit})',
- ).format(commit=build.commit[:8], **context)
- else:
- title = _('Failed: {project[name]} ({version[verbose_name]})').format(
- **context
- )
-
- send_email(
- email,
- title,
- template='projects/email/build_failed.txt',
- template_html='projects/email/build_failed.html',
- context=context,
- )
-
-
-def webhook_notification(version, build, hook_url):
- """
- Send webhook notification for project webhook.
-
- :param version: Version instance to send hook for
- :param build: Build instance that failed
- :param hook_url: Hook URL to send to
- """
- project = version.project
-
- data = json.dumps({
- 'name': project.name,
- 'slug': project.slug,
- 'build': {
- 'id': build.id,
- 'commit': build.commit,
- 'state': build.state,
- 'success': build.success,
- 'date': build.date.strftime('%Y-%m-%d %H:%M:%S'),
- },
- })
- log.debug(
- LOG_TEMPLATE,
- {
- 'project': project.slug,
- 'version': '',
- 'msg': 'sending notification to: %s' % hook_url,
- }
- )
- try:
- requests.post(
- hook_url,
- data=data,
- headers={'content-type': 'application/json'}
- )
- except Exception:
- log.exception('Failed to POST on webhook url: url=%s', hook_url)
-
-
# Random Tasks
@app.task()
def remove_dirs(paths):
diff --git a/readthedocs/projects/urls/private.py b/readthedocs/projects/urls/private.py
index e584e7d30e8..1d3a6551614 100644
--- a/readthedocs/projects/urls/private.py
+++ b/readthedocs/projects/urls/private.py
@@ -39,13 +39,18 @@
ProjectUpdate,
ProjectUsersCreateList,
ProjectUsersDelete,
- ProjectVersionDeleteHTML,
ProjectVersionCreate,
+ ProjectVersionDeleteHTML,
ProjectVersionDetail,
RegexAutomationRuleCreate,
RegexAutomationRuleUpdate,
SearchAnalytics,
TrafficAnalyticsView,
+ WebHookCreate,
+ WebHookDelete,
+ WebHookExchangeDetail,
+ WebHookList,
+ WebHookUpdate,
)
urlpatterns = [
@@ -337,3 +342,33 @@
]
urlpatterns += automation_rule_urls
+
+webhook_urls = [
+ url(
+ r'^(?P[-\w]+)/webhooks/$',
+ WebHookList.as_view(),
+ name='projects_webhooks',
+ ),
+ url(
+ r'^(?P[-\w]+)/webhooks/create/$',
+ WebHookCreate.as_view(),
+ name='projects_webhooks_create',
+ ),
+ url(
+ r'^(?P[-\w]+)/webhooks/(?P[-\w]+)/edit/$',
+ WebHookUpdate.as_view(),
+ name='projects_webhooks_edit',
+ ),
+ url(
+ r'^(?P[-\w]+)/webhooks/(?P[-\w]+)/delete/$',
+ WebHookDelete.as_view(),
+ name='projects_webhooks_delete',
+ ),
+ url(
+ r'^(?P[-\w]+)/webhooks/(?P[-\w]+)/exchanges/(?P[-\w]+)/$', # noqa
+ WebHookExchangeDetail.as_view(),
+ name='projects_webhooks_exchange',
+ ),
+]
+
+urlpatterns += webhook_urls
diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py
index 352631b5953..edf2ed89f1a 100644
--- a/readthedocs/projects/views/private.py
+++ b/readthedocs/projects/views/private.py
@@ -6,7 +6,7 @@
from allauth.socialaccount.models import SocialAccount
from django.conf import settings
from django.contrib import messages
-from django.db.models import Count
+from django.db.models import Count, Q
from django.http import (
Http404,
HttpResponseBadRequest,
@@ -19,7 +19,7 @@
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
-from django.views.generic import ListView, TemplateView, View
+from django.views.generic import ListView, TemplateView
from formtools.wizard.views import SessionWizardView
from vanilla import (
CreateView,
@@ -41,7 +41,6 @@
)
from readthedocs.core.history import UpdateChangeReasonPostView
from readthedocs.core.mixins import ListViewWithForm, PrivateViewMixin
-from readthedocs.core.utils import trigger_build
from readthedocs.core.utils.extend import SettingsOverrideObject
from readthedocs.integrations.models import HttpExchange, Integration
from readthedocs.oauth.services import registry
@@ -518,7 +517,6 @@ class ProjectNotifications(ProjectNotificationsMixin, TemplateView):
template_name = 'projects/project_notifications.html'
email_form = EmailHookForm
- webhook_form = WebHookForm
def get_email_form(self):
project = self.get_project()
@@ -527,37 +525,36 @@ def get_email_form(self):
project=project,
)
- def get_webhook_form(self):
- project = self.get_project()
- return self.webhook_form(
- self.request.POST or None,
- project=project,
- )
-
def post(self, request, *args, **kwargs):
if 'email' in request.POST:
email_form = self.get_email_form()
if email_form.is_valid():
email_form.save()
- elif 'url' in request.POST:
- webhook_form = self.get_webhook_form()
- if webhook_form.is_valid():
- webhook_form.save()
return HttpResponseRedirect(self.get_success_url())
+ def _has_old_webhooks(self):
+ """
+ Check if the project has webhooks from the old implementation created.
+
+ Webhooks from the old implementation don't have a custom payload.
+ """
+ project = self.get_project()
+ return (
+ project.webhook_notifications
+ .filter(Q(payload__isnull=True) | Q(payload=''))
+ .exists()
+ )
+
def get_context_data(self, **kwargs):
context = super().get_context_data()
project = self.get_project()
emails = project.emailhook_notifications.all()
- urls = project.webhook_notifications.all()
-
context.update(
{
'email_form': self.get_email_form(),
- 'webhook_form': self.get_webhook_form(),
'emails': emails,
- 'urls': urls,
+ 'has_old_webhooks': self._has_old_webhooks(),
},
)
return context
@@ -583,6 +580,65 @@ def post(self, request, *args, **kwargs):
return HttpResponseRedirect(self.get_success_url())
+class WebHookMixin(ProjectAdminMixin, PrivateViewMixin):
+
+ model = WebHook
+ lookup_url_kwarg = 'webhook_pk'
+ form_class = WebHookForm
+
+ def get_success_url(self):
+ return reverse(
+ 'projects_webhooks',
+ args=[self.get_project().slug],
+ )
+
+
+class WebHookList(WebHookMixin, ListView):
+
+ pass
+
+
+class WebHookCreate(WebHookMixin, CreateView):
+
+ def get_success_url(self):
+ return reverse(
+ 'projects_webhooks_edit',
+ args=[self.get_project().slug, self.object.pk],
+ )
+
+
+class WebHookUpdate(WebHookMixin, UpdateView):
+
+ def get_success_url(self):
+ return reverse(
+ 'projects_webhooks_edit',
+ args=[self.get_project().slug, self.object.pk],
+ )
+
+
+class WebHookDelete(WebHookMixin, DeleteView):
+
+ http_method_names = ['post']
+
+
+class WebHookExchangeDetail(WebHookMixin, DetailView):
+
+ model = HttpExchange
+ lookup_url_kwarg = 'webhook_exchange_pk'
+ webhook_url_kwarg = 'webhook_pk'
+ template_name = 'projects/webhook_exchange_detail.html'
+
+ def get_queryset(self):
+ return self.model.objects.filter(webhook=self.get_webhook())
+
+ def get_webhook(self):
+ return get_object_or_404(
+ WebHook,
+ pk=self.kwargs[self.webhook_url_kwarg],
+ project=self.get_project(),
+ )
+
+
class ProjectTranslationsMixin(ProjectAdminMixin, PrivateViewMixin):
def get_success_url(self):
diff --git a/readthedocs/rtd_tests/base.py b/readthedocs/rtd_tests/base.py
index 22d9a2a9574..ab4847e4fe5 100644
--- a/readthedocs/rtd_tests/base.py
+++ b/readthedocs/rtd_tests/base.py
@@ -66,7 +66,7 @@ class WizardTestCase(RequestFactoryTestMixin, TestCase):
wizard_class_slug = None
wizard_class = None
- @patch('readthedocs.projects.views.private.trigger_build', lambda x: None)
+ @patch('readthedocs.core.utils.trigger_build', lambda x: None)
def post_step(self, step, **kwargs):
"""
Post step form data to `url`, using supplementary `kwargs`
diff --git a/readthedocs/rtd_tests/tests/test_build_notifications.py b/readthedocs/rtd_tests/tests/test_build_notifications.py
index a92131f485a..8967ef8ee52 100644
--- a/readthedocs/rtd_tests/tests/test_build_notifications.py
+++ b/readthedocs/rtd_tests/tests/test_build_notifications.py
@@ -1,62 +1,90 @@
"""Notifications sent after build is completed."""
-
+import hashlib
+import hmac
import json
+from unittest import mock
-import django_dynamic_fixture as fixture
+import requests_mock
from django.core import mail
from django.test import TestCase
-from unittest.mock import patch
+from django_dynamic_fixture import get
from readthedocs.builds.constants import EXTERNAL
from readthedocs.builds.models import Build, Version
+from readthedocs.builds.tasks import send_build_notifications
from readthedocs.projects.forms import WebHookForm
-from readthedocs.projects.models import EmailHook, Project, WebHook
-from readthedocs.projects.tasks import send_notifications
+from readthedocs.projects.models import (
+ EmailHook,
+ Project,
+ WebHook,
+ WebHookEvent,
+)
class BuildNotificationsTests(TestCase):
+
def setUp(self):
- self.project = fixture.get(Project)
- self.version = fixture.get(Version, project=self.project)
- self.build = fixture.get(Build, version=self.version)
+ self.project = get(Project)
+ self.version = get(Version, project=self.project)
+ self.build = get(Build, version=self.version, commit='abc123')
- @patch('readthedocs.builds.managers.log')
+ @mock.patch('readthedocs.builds.managers.log')
def test_send_notification_none_if_wrong_version_pk(self, mock_logger):
self.assertFalse(Version.objects.filter(pk=345343).exists())
- send_notifications(version_pk=345343, build_pk=None)
+ send_build_notifications(
+ version_pk=345343,
+ build_pk=None,
+ event=WebHookEvent.BUILD_FAILED,
+ )
mock_logger.warning.assert_called_with(
'Version not found for given kwargs. %s',
{'pk': 345343},
)
-
def test_send_notification_none(self):
- send_notifications(self.version.pk, self.build.pk)
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
self.assertEqual(len(mail.outbox), 0)
- def test_send_webhook_notification(self):
- fixture.get(WebHook, project=self.project)
- with patch('readthedocs.projects.tasks.requests.post') as mock:
- mock.return_value = None
- send_notifications(self.version.pk, self.build.pk)
- mock.assert_called_once()
-
+ @requests_mock.Mocker(kw='mock_request')
+ def test_send_webhook_notification(self, mock_request):
+ webhook = get(
+ WebHook,
+ url='https://example.com/webhook/',
+ project=self.project,
+ events=[WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
+ )
+ self.assertEqual(webhook.exchanges.all().count(), 0)
+ mock_request.post(webhook.url)
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
+ self.assertEqual(webhook.exchanges.all().count(), 1)
self.assertEqual(len(mail.outbox), 0)
def test_dont_send_webhook_notifications_for_external_versions(self):
- fixture.get(WebHook, project=self.project)
+ webhook = get(WebHook, url='https://example.com/webhook/', project=self.project)
self.version.type = EXTERNAL
self.version.save()
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
+ self.assertEqual(webhook.exchanges.all().count(), 0)
- with patch('readthedocs.projects.tasks.requests.post') as mock:
- mock.return_value = None
- send_notifications(self.version.pk, self.build.pk)
- mock.assert_not_called()
-
- self.assertEqual(len(mail.outbox), 0)
-
- def test_send_webhook_notification_has_content_type_header(self):
- hook = fixture.get(WebHook, project=self.project)
+ def test_webhook_notification_has_content_type_header(self):
+ webhook = get(
+ WebHook,
+ url='https://example.com/webhook/',
+ project=self.project,
+ events=[WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
+ )
data = json.dumps({
'name': self.project.name,
'slug': self.project.slug,
@@ -68,49 +96,221 @@ def test_send_webhook_notification_has_content_type_header(self):
'date': self.build.date.strftime('%Y-%m-%d %H:%M:%S'),
},
})
- with patch('readthedocs.projects.tasks.requests.post') as mock:
- mock.return_value = None
- send_notifications(self.version.pk, self.build.pk)
- mock.assert_called_once_with(
- hook.url,
+ with mock.patch('readthedocs.builds.tasks.requests.post') as post:
+ post.return_value = None
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
+ post.assert_called_once_with(
+ webhook.url,
data=data,
- headers={'content-type': 'application/json'}
+ headers={
+ 'content-type': 'application/json',
+ 'X-Hub-Signature': mock.ANY,
+ 'User-Agent': mock.ANY,
+ },
+ timeout=mock.ANY,
+ )
+
+ @requests_mock.Mocker(kw='mock_request')
+ def test_send_webhook_custom_on_given_event(self, mock_request):
+ webhook = get(
+ WebHook,
+ url='https://example.com/webhook/',
+ project=self.project,
+ events=[
+ WebHookEvent.objects.get(name=WebHookEvent.BUILD_TRIGGERED),
+ WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED),
+ ],
+ payload='{}',
+ )
+ mock_request.post(webhook.url)
+ for event, _ in WebHookEvent.EVENTS:
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=event,
)
+ self.assertEqual(len(mail.outbox), 0)
+ self.assertEqual(webhook.exchanges.all().count(), 2)
- def test_send_email_notification(self):
- fixture.get(EmailHook, project=self.project)
- send_notifications(self.version.pk, self.build.pk, email=True)
+ @requests_mock.Mocker(kw='mock_request')
+ def test_send_webhook_custom_payload(self, mock_request):
+ webhook = get(
+ WebHook,
+ url='https://example.com/webhook/',
+ project=self.project,
+ events=[WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED)],
+ payload=json.dumps({
+ 'message': 'Event ${event} triggered for ${version.slug}',
+ 'extra-data': {
+ 'build_id': '${build.id}',
+ 'build_commit': '${build.commit}',
+ 'organization_slug': '${organization.slug}',
+ 'organization_name': '${organization.name}',
+ 'project_slug': '${project.slug}',
+ 'project_name': '${project.name}',
+ 'version_slug': '${version.slug}',
+ 'version_name': '${version.name}',
+ 'invalid_substitution': '${invalid.substitution}',
+ }
+ }),
+ )
+ post = mock_request.post(webhook.url)
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
+ self.assertTrue(post.called_once)
+ request = post.request_history[0]
+ self.assertEqual(
+ request.json(),
+ {
+ 'message': f'Event build:failed triggered for {self.version.slug}',
+ 'extra-data': {
+ 'build_id': str(self.build.pk),
+ 'build_commit': self.build.commit,
+ 'organization_name': '',
+ 'organization_slug': '',
+ 'project_name': self.project.name,
+ 'project_slug': self.project.slug,
+ 'version_name': self.version.verbose_name,
+ 'version_slug': self.version.slug,
+ 'invalid_substitution': '${invalid.substitution}',
+ },
+ }
+ )
+ self.assertEqual(webhook.exchanges.all().count(), 1)
+
+ @requests_mock.Mocker(kw='mock_request')
+ def test_webhook_headers(self, mock_request):
+ secret = '1234'
+ webhook = get(
+ WebHook,
+ url='https://example.com/webhook/',
+ project=self.project,
+ events=[WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED)],
+ payload='{"sign": "me"}',
+ secret=secret,
+ )
+ post = mock_request.post(webhook.url)
+ signature = hmac.new(
+ key=secret.encode(),
+ msg=webhook.payload.encode(),
+ digestmod=hashlib.sha1,
+ ).hexdigest()
+
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
+ self.assertTrue(post.called_once)
+ request = post.request_history[0]
+ headers = request.headers
+ self.assertTrue(headers['User-Agent'].startswith('Read-the-Docs/'))
+ self.assertEqual(headers['X-Hub-Signature'], signature)
+ self.assertEqual(webhook.exchanges.all().count(), 1)
+
+ @requests_mock.Mocker(kw='mock_request')
+ def test_webhook_record_exchange(self, mock_request):
+ webhook = get(
+ WebHook,
+ url='https://example.com/webhook/',
+ project=self.project,
+ events=[WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED)],
+ payload='{"request": "ok"}',
+ )
+ post = mock_request.post(
+ webhook.url,
+ json={'response': 'ok'},
+ headers={'X-Greeting': 'Hi!'},
+ status_code=201,
+ )
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
+ self.assertTrue(post.called_once)
+ self.assertEqual(webhook.exchanges.all().count(), 1)
+ exchange = webhook.exchanges.all().first()
+ self.assertTrue(exchange.request_headers['User-Agent'].startswith('Read-the-Docs/'))
+ self.assertIn('X-Hub-Signature', exchange.request_headers)
+ self.assertEqual(exchange.request_body, webhook.payload)
+ self.assertEqual(exchange.response_headers, {'X-Greeting': 'Hi!'})
+ self.assertEqual(exchange.response_body, '{"response": "ok"}')
+ self.assertEqual(exchange.status_code, 201)
+
+ def test_send_email_notification_on_build_failure(self):
+ get(EmailHook, project=self.project)
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
self.assertEqual(len(mail.outbox), 1)
def test_dont_send_email_notifications_for_external_versions(self):
- fixture.get(EmailHook, project=self.project)
+ get(EmailHook, project=self.project)
self.version.type = EXTERNAL
self.version.save()
- send_notifications(self.version.pk, self.build.pk, email=True)
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
+ self.assertEqual(len(mail.outbox), 0)
+
+ def test_dont_send_email_notifications_for_other_events(self):
+ """Email notifications are only send for BUILD_FAILED events."""
+ get(EmailHook, project=self.project)
+ for event in [WebHookEvent.BUILD_PASSED, WebHookEvent.BUILD_TRIGGERED]:
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=event,
+ )
self.assertEqual(len(mail.outbox), 0)
- def test_send_email_and_webhook__notification(self):
- fixture.get(EmailHook, project=self.project)
- fixture.get(WebHook, project=self.project)
- with patch('readthedocs.projects.tasks.requests.post') as mock:
- mock.return_value = None
- send_notifications(self.version.pk, self.build.pk, email=True)
- mock.assert_called_once()
+ @requests_mock.Mocker(kw='mock_request')
+ def test_send_email_and_webhook_notification(self, mock_request):
+ get(EmailHook, project=self.project)
+ webhook = get(
+ WebHook,
+ url='https://example.com/webhook/',
+ project=self.project,
+ events=[WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
+ )
+ mock_request.post(webhook.url)
+ self.assertEqual(len(mail.outbox), 0)
+ self.assertEqual(webhook.exchanges.all().count(), 0)
+ send_build_notifications(
+ version_pk=self.version.pk,
+ build_pk=self.build.pk,
+ event=WebHookEvent.BUILD_FAILED,
+ )
self.assertEqual(len(mail.outbox), 1)
+ self.assertEqual(webhook.exchanges.all().count(), 1)
class TestForms(TestCase):
def setUp(self):
- self.project = fixture.get(Project)
- self.version = fixture.get(Version, project=self.project)
- self.build = fixture.get(Build, version=self.version)
+ self.project = get(Project)
+ self.version = get(Version, project=self.project)
+ self.build = get(Build, version=self.version)
def test_webhook_form_url_length(self):
form = WebHookForm(
{
'url': 'https://foobar.com',
+ 'payload': '{}',
+ 'events': [WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
},
project=self.project,
)
@@ -119,6 +319,8 @@ def test_webhook_form_url_length(self):
form = WebHookForm(
{
'url': 'foo' * 500,
+ 'payload': '{}',
+ 'events': [WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
},
project=self.project,
)
diff --git a/readthedocs/rtd_tests/tests/test_privacy_urls.py b/readthedocs/rtd_tests/tests/test_privacy_urls.py
index 24b0b9e7e0b..193c5b72ce8 100644
--- a/readthedocs/rtd_tests/tests/test_privacy_urls.py
+++ b/readthedocs/rtd_tests/tests/test_privacy_urls.py
@@ -18,7 +18,12 @@
from readthedocs.core.utils.tasks import TaskNoPermission
from readthedocs.integrations.models import HttpExchange, Integration
from readthedocs.oauth.models import RemoteOrganization, RemoteRepository
-from readthedocs.projects.models import Domain, EnvironmentVariable, Project
+from readthedocs.projects.models import (
+ Domain,
+ EnvironmentVariable,
+ Project,
+ WebHook,
+)
from readthedocs.rtd_tests.utils import create_user
@@ -160,7 +165,7 @@ def setUp(self):
self.pip.translations.add(self.subproject)
self.integration = get(Integration, project=self.pip, provider_data='')
# For whatever reason, fixtures hates JSONField
- self.webhook_exchange = HttpExchange.objects.create(
+ self.integration_exchange = HttpExchange.objects.create(
related_object=self.integration,
request_headers='{"foo": "bar"}',
response_headers='{"foo": "bar"}',
@@ -175,6 +180,13 @@ def setUp(self):
action=VersionAutomationRule.ACTIVATE_VERSION_ACTION,
version_type=BRANCH,
)
+ self.webhook = get(WebHook, project=self.pip)
+ self.webhook_exchange = HttpExchange.objects.create(
+ related_object=self.webhook,
+ request_headers='{"foo": "bar"}',
+ response_headers='{"foo": "bar"}',
+ status_code=200,
+ )
self.default_kwargs = {
'project_slug': self.pip.slug,
'subproject_slug': self.subproject.slug,
@@ -186,11 +198,13 @@ def setUp(self):
'build_pk': self.build.pk,
'domain_pk': self.domain.pk,
'integration_pk': self.integration.pk,
- 'exchange_pk': self.webhook_exchange.pk,
+ 'exchange_pk': self.integration_exchange.pk,
'environmentvariable_pk': self.environment_variable.pk,
'automation_rule_pk': self.automation_rule.pk,
'steps': 1,
'invalid_project_slug': 'invalid_slug',
+ 'webhook_pk': self.webhook.pk,
+ 'webhook_exchange_pk': self.webhook_exchange.pk,
}
@@ -253,7 +267,7 @@ def is_admin(self):
# ## Private Project Testing ###
-@mock.patch('readthedocs.projects.views.private.trigger_build', mock.MagicMock())
+@mock.patch('readthedocs.core.utils.trigger_build', mock.MagicMock())
class PrivateProjectAdminAccessTest(PrivateProjectMixin, TestCase):
response_data = {
@@ -275,6 +289,7 @@ class PrivateProjectAdminAccessTest(PrivateProjectMixin, TestCase):
'/dashboard/pip/version/latest/delete_html/': {'status_code': 405},
'/dashboard/pip/rules/{automation_rule_id}/delete/': {'status_code': 405},
'/dashboard/pip/rules/{automation_rule_id}/move/{steps}/': {'status_code': 405},
+ '/dashboard/pip/webhooks/{webhook_id}/delete/': {'status_code': 405},
}
def get_url_path_ctx(self):
@@ -282,6 +297,7 @@ def get_url_path_ctx(self):
'integration_id': self.integration.id,
'environmentvariable_id': self.environment_variable.id,
'automation_rule_id': self.automation_rule.id,
+ 'webhook_id': self.webhook.id,
'steps': 1,
}
@@ -292,7 +308,7 @@ def is_admin(self):
return True
-@mock.patch('readthedocs.projects.views.private.trigger_build', mock.MagicMock())
+@mock.patch('readthedocs.core.utils.trigger_build', mock.MagicMock())
class PrivateProjectUserAccessTest(PrivateProjectMixin, TestCase):
response_data = {
@@ -318,6 +334,7 @@ class PrivateProjectUserAccessTest(PrivateProjectMixin, TestCase):
'/dashboard/pip/version/latest/delete_html/': {'status_code': 405},
'/dashboard/pip/rules/{automation_rule_id}/delete/': {'status_code': 405},
'/dashboard/pip/rules/{automation_rule_id}/move/{steps}/': {'status_code': 405},
+ '/dashboard/pip/webhooks/{webhook_id}/delete/': {'status_code': 405},
}
# Filtered out by queryset on projects that we don't own.
@@ -328,6 +345,7 @@ def get_url_path_ctx(self):
'integration_id': self.integration.id,
'environmentvariable_id': self.environment_variable.id,
'automation_rule_id': self.automation_rule.id,
+ 'webhook_id': self.webhook.id,
'steps': 1,
}
diff --git a/readthedocs/rtd_tests/tests/test_project_forms.py b/readthedocs/rtd_tests/tests/test_project_forms.py
index 3921e019bef..11e52d8745f 100644
--- a/readthedocs/rtd_tests/tests/test_project_forms.py
+++ b/readthedocs/rtd_tests/tests/test_project_forms.py
@@ -27,7 +27,11 @@
UpdateProjectForm,
WebHookForm,
)
-from readthedocs.projects.models import EnvironmentVariable, Project
+from readthedocs.projects.models import (
+ EnvironmentVariable,
+ Project,
+ WebHookEvent,
+)
class TestProjectForms(TestCase):
@@ -665,7 +669,7 @@ def test_can_change_language_to_self_lang(self):
self.assertTrue(form.is_valid())
-class TestNotificationForm(TestCase):
+class TestWebhookForm(TestCase):
def setUp(self):
self.project = get(Project)
@@ -674,7 +678,9 @@ def test_webhookform(self):
self.assertEqual(self.project.webhook_notifications.all().count(), 0)
data = {
- 'url': 'http://www.example.com/'
+ 'url': 'http://www.example.com/',
+ 'payload': '{}',
+ 'events': [WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
}
form = WebHookForm(data=data, project=self.project)
self.assertTrue(form.is_valid())
@@ -682,7 +688,9 @@ def test_webhookform(self):
self.assertEqual(self.project.webhook_notifications.all().count(), 1)
data = {
- 'url': 'https://www.example.com/'
+ 'url': 'https://www.example.com/',
+ 'payload': '{}',
+ 'events': [WebHookEvent.objects.get(name=WebHookEvent.BUILD_PASSED).id],
}
form = WebHookForm(data=data, project=self.project)
self.assertTrue(form.is_valid())
@@ -693,7 +701,9 @@ def test_wrong_inputs_in_webhookform(self):
self.assertEqual(self.project.webhook_notifications.all().count(), 0)
data = {
- 'url': ''
+ 'url': '',
+ 'payload': '{}',
+ 'events': [WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
}
form = WebHookForm(data=data, project=self.project)
self.assertFalse(form.is_valid())
@@ -701,13 +711,41 @@ def test_wrong_inputs_in_webhookform(self):
self.assertEqual(self.project.webhook_notifications.all().count(), 0)
data = {
- 'url': 'wrong-url'
+ 'url': 'wrong-url',
+ 'payload': '{}',
+ 'events': [WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
}
form = WebHookForm(data=data, project=self.project)
self.assertFalse(form.is_valid())
self.assertDictEqual(form.errors, {'url': ['Enter a valid URL.']})
self.assertEqual(self.project.webhook_notifications.all().count(), 0)
+ data = {
+ 'url': 'https://example.com/webhook/',
+ 'payload': '{wrong json object}',
+ 'events': [WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
+ }
+ form = WebHookForm(data=data, project=self.project)
+ self.assertFalse(form.is_valid())
+ self.assertDictEqual(form.errors, {'payload': ['The payload must be a valid JSON object.']})
+ self.assertEqual(self.project.webhook_notifications.all().count(), 0)
+
+ data = {
+ 'url': 'https://example.com/webhook/',
+ 'payload': '{}',
+ 'events': [],
+ }
+ form = WebHookForm(data=data, project=self.project)
+ self.assertFalse(form.is_valid())
+ self.assertDictEqual(form.errors, {'events': ['This field is required.']})
+ self.assertEqual(self.project.webhook_notifications.all().count(), 0)
+
+
+class TestNotificationForm(TestCase):
+
+ def setUp(self):
+ self.project = get(Project)
+
def test_emailhookform(self):
self.assertEqual(self.project.emailhook_notifications.all().count(), 0)
diff --git a/readthedocs/rtd_tests/tests/test_project_views.py b/readthedocs/rtd_tests/tests/test_project_views.py
index 3c20e200705..b58e1b1e29c 100644
--- a/readthedocs/rtd_tests/tests/test_project_views.py
+++ b/readthedocs/rtd_tests/tests/test_project_views.py
@@ -1,10 +1,8 @@
from datetime import timedelta
from unittest import mock
-from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialAccount
from django.contrib.auth.models import User
-from django.contrib.messages import constants as message_const
from django.http.response import HttpResponseRedirect
from django.test import TestCase
from django.urls import reverse
diff --git a/readthedocs/templates/projects/project_edit_base.html b/readthedocs/templates/projects/project_edit_base.html
index 2199bd0005d..d1b3f944400 100644
--- a/readthedocs/templates/projects/project_edit_base.html
+++ b/readthedocs/templates/projects/project_edit_base.html
@@ -26,7 +26,8 @@
{% trans "Integrations" %}
{% trans "Environment Variables" %}
{% trans "Automation Rules" %}
- {% trans "Notifications" %}
+ {% trans "Webhooks" %}
+ {% trans "Email Notifications" %}
{% trans "Traffic Analytics" %}
{% trans "Search Analytics" %}
{% if USE_PROMOS %}
diff --git a/readthedocs/templates/projects/project_notifications.html b/readthedocs/templates/projects/project_notifications.html
index f3f7bb6e8b5..b52f22f1d6b 100644
--- a/readthedocs/templates/projects/project_notifications.html
+++ b/readthedocs/templates/projects/project_notifications.html
@@ -2,20 +2,30 @@
{% load i18n %}
-{% block title %}{% trans "Edit Notifications" %}{% endblock %}
+{% block title %}{% trans "Email Notifications" %}{% endblock %}
{% block nav-dashboard %} class="active"{% endblock %}
{% block editing-option-edit-proj %}class="active"{% endblock %}
{% block project-notifications-active %}active{% endblock %}
-{% block project_edit_content_header %}{% trans "Notifications" %}{% endblock %}
+{% block project_edit_content_header %}{% trans "Email Notifications" %}{% endblock %}
{% block project_edit_content %}
- {% trans "Configure Notifications to be sent on build failures." %}
+ {% trans "Configure email notifications to be sent on build failures." %}
+ {% if has_old_webhooks %}
+
+ {% url 'projects_webhooks' project.slug as webhooks_url %}
+ {% blocktrans trimmed with webhooks_url=webhooks_url %}
+ Webhooks have been moved to its own page.
+ Go to the Webhooks tab if you are looking for your webhooks.
+ {% endblocktrans %}
+
+ {% endif %}
+
{% if emails or urls %}
{% trans "Existing Notifications" %}
@@ -69,15 +79,4 @@
{% trans "New Email Notifications" %}
-
- {% trans "New Webhook Notifications" %}
-
- {% trans "Add a URL to notify" %}
-
-
{% endblock %}
diff --git a/readthedocs/templates/projects/webhook_exchange_detail.html b/readthedocs/templates/projects/webhook_exchange_detail.html
new file mode 100644
index 00000000000..96363648ef5
--- /dev/null
+++ b/readthedocs/templates/projects/webhook_exchange_detail.html
@@ -0,0 +1,39 @@
+{% extends "projects/project_edit_base.html" %}
+
+{% load i18n %}
+
+{% block title %}{% trans "Webhooks" %}{% endblock %}
+
+{% block nav-dashboard %} class="active"{% endblock %}
+
+{% block project-webhooks-active %}active{% endblock %}
+{% block project_edit_content_header %}{% trans "Exchange" %}{% endblock %}
+
+{% block project_edit_content %}
+ Response
+
+
+
+ {{ httpexchange.formatted_response_body }}
+
+
+ Request
+
+
+
+ {{ httpexchange.formatted_request_body }}
+
+{% endblock %}
diff --git a/readthedocs/templates/projects/webhook_form.html b/readthedocs/templates/projects/webhook_form.html
new file mode 100644
index 00000000000..5e3f030f1d8
--- /dev/null
+++ b/readthedocs/templates/projects/webhook_form.html
@@ -0,0 +1,70 @@
+{% extends "projects/project_edit_base.html" %}
+
+{% load i18n %}
+
+{% block title %}{% trans "Webhooks" %}{% endblock %}
+
+{% block nav-dashboard %} class="active"{% endblock %}
+
+{% block project-webhooks-active %}active{% endblock %}
+
+{% block project_edit_content_header %}{% trans "Webhooks" %}{% endblock %}
+
+{% block project_edit_content %}
+
+
+ {% blocktrans trimmed %}
+ We’ll send a POST
request to the URL with the JSON payload below on the selected events.
+ {% endblocktrans %}
+
+
+ {# If the webhook was created from the old implementation, it doesn't have a custom payload. #}
+ {% if object.pk and not object.payload %}
+
+ {% blocktrans trimmed with docs_url="https://docs.readthedocs.io/page/webhooks.html" %}
+ This webhook was created from our old implementation and doesn't have a custom payload.
+ Check our docs for the payload description or edit the payload to set a custom one.
+ {% endblocktrans %}
+
+ {% endif %}
+
+
+
+ {% if object.pk %}
+
+
+ {% trans "Recent Activity" %}
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/readthedocs/templates/projects/webhook_list.html b/readthedocs/templates/projects/webhook_list.html
new file mode 100644
index 00000000000..575a67273e6
--- /dev/null
+++ b/readthedocs/templates/projects/webhook_list.html
@@ -0,0 +1,56 @@
+{% extends "projects/project_edit_base.html" %}
+
+{% load i18n %}
+
+{% block title %}{% trans "Webhooks" %}{% endblock %}
+
+{% block nav-dashboard %} class="active"{% endblock %}
+
+{% block project-webhooks-active %}active{% endblock %}
+{% block project_edit_content_header %}{% trans "Webhooks" %}{% endblock %}
+
+{% block project_edit_content %}
+ Something about webhooks
+
+
+
+
+{% endblock %}
From ed703fdd4df69bd2f0b327238c69d4c8e95b2726 Mon Sep 17 00:00:00 2001
From: Santos Gallegos
Date: Wed, 29 Sep 2021 13:59:56 -0500
Subject: [PATCH 02/42] Rename unsub_url -> unsubscribe_url
---
readthedocs/builds/tasks.py | 2 +-
readthedocs/templates/projects/email/build_failed.html | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/readthedocs/builds/tasks.py b/readthedocs/builds/tasks.py
index ef6bcaf64d6..5d2a157768a 100644
--- a/readthedocs/builds/tasks.py
+++ b/readthedocs/builds/tasks.py
@@ -542,7 +542,7 @@ def send_email(self, email):
settings.PRODUCTION_DOMAIN,
self.build.get_absolute_url(),
),
- 'unsub_url': 'https://{}{}'.format(
+ 'unsubscribe_url': 'https://{}{}'.format(
settings.PRODUCTION_DOMAIN,
reverse('projects_notifications', args=[self.project.slug]),
),
diff --git a/readthedocs/templates/projects/email/build_failed.html b/readthedocs/templates/projects/email/build_failed.html
index dc466abfe1c..9f49a8048d8 100644
--- a/readthedocs/templates/projects/email/build_failed.html
+++ b/readthedocs/templates/projects/email/build_failed.html
@@ -33,6 +33,6 @@
- You can unsubscribe from these emails in your Notification Settings
+ You can unsubscribe from these emails in your Notification Settings
{% endblock %}
From 224622e899e0b9228c66487548b24d74702c232c Mon Sep 17 00:00:00 2001
From: Santos Gallegos
Date: Wed, 29 Sep 2021 15:37:50 -0500
Subject: [PATCH 03/42] Update migrations
---
...2_init_generic_webhooks.py => 0083_init_generic_webhooks.py} | 2 +-
...3_create_webhook_events.py => 0084_create_webhook_events.py} | 2 +-
...ks_to_events.py => 0085_subscribe_old_webhooks_to_events.py} | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
rename readthedocs/projects/migrations/{0082_init_generic_webhooks.py => 0083_init_generic_webhooks.py} (97%)
rename readthedocs/projects/migrations/{0083_create_webhook_events.py => 0084_create_webhook_events.py} (90%)
rename readthedocs/projects/migrations/{0084_subscribe_old_webhooks_to_events.py => 0085_subscribe_old_webhooks_to_events.py} (93%)
diff --git a/readthedocs/projects/migrations/0082_init_generic_webhooks.py b/readthedocs/projects/migrations/0083_init_generic_webhooks.py
similarity index 97%
rename from readthedocs/projects/migrations/0082_init_generic_webhooks.py
rename to readthedocs/projects/migrations/0083_init_generic_webhooks.py
index 5b442aaa4a2..83011bcb089 100644
--- a/readthedocs/projects/migrations/0082_init_generic_webhooks.py
+++ b/readthedocs/projects/migrations/0083_init_generic_webhooks.py
@@ -8,7 +8,7 @@
class Migration(migrations.Migration):
dependencies = [
- ('projects', '0081_add_another_header'),
+ ('projects', '0082_add_extra_history_fields'),
]
operations = [
diff --git a/readthedocs/projects/migrations/0083_create_webhook_events.py b/readthedocs/projects/migrations/0084_create_webhook_events.py
similarity index 90%
rename from readthedocs/projects/migrations/0083_create_webhook_events.py
rename to readthedocs/projects/migrations/0084_create_webhook_events.py
index 7002a92abd3..f8fef1d695e 100644
--- a/readthedocs/projects/migrations/0083_create_webhook_events.py
+++ b/readthedocs/projects/migrations/0084_create_webhook_events.py
@@ -13,7 +13,7 @@ def forwards_func(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('projects', '0082_init_generic_webhooks'),
+ ('projects', '0083_init_generic_webhooks'),
]
operations = [
diff --git a/readthedocs/projects/migrations/0084_subscribe_old_webhooks_to_events.py b/readthedocs/projects/migrations/0085_subscribe_old_webhooks_to_events.py
similarity index 93%
rename from readthedocs/projects/migrations/0084_subscribe_old_webhooks_to_events.py
rename to readthedocs/projects/migrations/0085_subscribe_old_webhooks_to_events.py
index 26a88a1488a..36b9d9819df 100644
--- a/readthedocs/projects/migrations/0084_subscribe_old_webhooks_to_events.py
+++ b/readthedocs/projects/migrations/0085_subscribe_old_webhooks_to_events.py
@@ -21,7 +21,7 @@ def forwards_func(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('projects', '0083_create_webhook_events'),
+ ('projects', '0084_create_webhook_events'),
]
operations = [
From c5312865666fd8bf76056cb7cf7e28d2446d01c1 Mon Sep 17 00:00:00 2001
From: Santos Gallegos
Date: Wed, 29 Sep 2021 15:53:45 -0500
Subject: [PATCH 04/42] Fix merge conflict
---
readthedocs/projects/views/private.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py
index ae3bf18cf48..d889b218879 100644
--- a/readthedocs/projects/views/private.py
+++ b/readthedocs/projects/views/private.py
@@ -5,7 +5,7 @@
from allauth.socialaccount.models import SocialAccount
from django.conf import settings
from django.contrib import messages
-from django.db.models import Count
+from django.db.models import Count, Q
from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect
from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404
From 1a5c5b78601457daa60565f1dce33e1f1be63c76 Mon Sep 17 00:00:00 2001
From: Santos Gallegos
Date: Mon, 4 Oct 2021 16:47:35 -0500
Subject: [PATCH 05/42] Add more susbtitutions
---
readthedocs/builds/tasks.py | 13 +++++++-----
.../migrations/0084_create_webhook_events.py | 2 +-
readthedocs/projects/models.py | 15 ++++++++++++-
.../tests/test_build_notifications.py | 21 +++++++++++++++----
.../templates/projects/webhook_list.html | 2 --
5 files changed, 40 insertions(+), 13 deletions(-)
diff --git a/readthedocs/builds/tasks.py b/readthedocs/builds/tasks.py
index 5d2a157768a..878a9b25f47 100644
--- a/readthedocs/builds/tasks.py
+++ b/readthedocs/builds/tasks.py
@@ -1,4 +1,3 @@
-import itertools
import json
import logging
from datetime import datetime, timedelta
@@ -490,7 +489,7 @@ def __init__(self, version, build, event):
def send(self):
"""
- Send email and webhook notifications for `project`.
+ Send email and webhook notifications for `project` about the `build`.
Email notifications are only send for build:failed events.
Webhooks choose to what events they subscribe to.
@@ -527,6 +526,7 @@ def send_email(self, email):
"""Send email notifications for build failures."""
# We send only what we need from the Django model objects here to avoid
# serialization problems in the ``readthedocs.core.tasks.send_email_task``
+ protocol = 'http' if settings.DEBUG else 'https'
context = {
'version': {
'verbose_name': self.version.verbose_name,
@@ -538,11 +538,13 @@ def send_email(self, email):
'pk': self.build.pk,
'error': self.build.error,
},
- 'build_url': 'https://{}{}'.format(
+ 'build_url': '{}://{}{}'.format(
+ protocol,
settings.PRODUCTION_DOMAIN,
self.build.get_absolute_url(),
),
- 'unsubscribe_url': 'https://{}{}'.format(
+ 'unsubscribe_url': '{}://{}{}'.format(
+ protocol,
settings.PRODUCTION_DOMAIN,
reverse('projects_notifications', args=[self.project.slug]),
),
@@ -604,7 +606,8 @@ def send_webhook(self, webhook):
headers = {
'content-type': 'application/json',
- 'User-Agent': f'Read-the-Docs/{__version__}'
+ 'User-Agent': f'Read-the-Docs/{__version__} ({settings.PRODUCTION_DOMAIN})',
+ 'X-RTD-Event': self.event,
}
if webhook.secret:
headers['X-Hub-Signature'] = webhook.sign_payload(payload)
diff --git a/readthedocs/projects/migrations/0084_create_webhook_events.py b/readthedocs/projects/migrations/0084_create_webhook_events.py
index f8fef1d695e..738b68fd4c0 100644
--- a/readthedocs/projects/migrations/0084_create_webhook_events.py
+++ b/readthedocs/projects/migrations/0084_create_webhook_events.py
@@ -4,7 +4,7 @@
def forwards_func(apps, schema_editor):
- """Sets all null ranks to zero."""
+ """Create supported events for webhooks."""
WebHookEvent = apps.get_model('projects', 'WebHookEvent')
for event in ['build:triggered', 'build:failed', 'build:passed']:
WebHookEvent.objects.get_or_create(name=event)
diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py
index 06d533a47f2..a467b323a29 100644
--- a/readthedocs/projects/models.py
+++ b/readthedocs/projects/models.py
@@ -1551,14 +1551,27 @@ def get_payload(self, version, build, event):
organization_slug = organization.slug
organization_name = organization.name
+ # Commit can be None, display an empty string instead.
+ commit = build.commit or ''
+ protocol = 'http' if settings.DEBUG else 'https'
+ project_url = f'{protocol}://{settings.PRODUCTION_DOMAIN}{project.get_absolute_url()}'
+ build_url = f'{protocol}://{settings.PRODUCTION_DOMAIN}{build.get_absolute_url()}'
+ build_docsurl = self.project.get_docs_url(
+ version_slug=self.version.slug,
+ external=self.version.is_external,
+ )
+
substitutions = {
'${event}': event,
'${build.id}': build.id,
- '${build.commit}': build.commit or '',
+ '${build.commit}': commit,
+ '${build.url}': build_url,
+ '${build.docsurl}': build_docsurl,
'${organization.name}': organization_name,
'${organization.slug}': organization_slug,
'${project.slug}': project.slug,
'${project.name}': project.name,
+ '${project.url}': project_url,
'${version.slug}': version.slug,
'${version.name}': version.verbose_name,
}
diff --git a/readthedocs/rtd_tests/tests/test_build_notifications.py b/readthedocs/rtd_tests/tests/test_build_notifications.py
index 8967ef8ee52..d99cabc4c77 100644
--- a/readthedocs/rtd_tests/tests/test_build_notifications.py
+++ b/readthedocs/rtd_tests/tests/test_build_notifications.py
@@ -6,7 +6,7 @@
import requests_mock
from django.core import mail
-from django.test import TestCase
+from django.test import TestCase, override_settings
from django_dynamic_fixture import get
from readthedocs.builds.constants import EXTERNAL
@@ -21,12 +21,17 @@
)
+override_settings(
+ PRODUCTION_DOMAIN='readthedocs.org',
+ PUBLIC_DOMAIN='readthedocs.io',
+ USE_SUBDOMAIN=True,
+)
class BuildNotificationsTests(TestCase):
def setUp(self):
- self.project = get(Project)
- self.version = get(Version, project=self.project)
- self.build = get(Build, version=self.version, commit='abc123')
+ self.project = get(Project, slug='test', language='en')
+ self.version = get(Version, project=self.project, slug='1.0')
+ self.build = get(Build, version=self.version, commit='abc1234567890')
@mock.patch('readthedocs.builds.managers.log')
def test_send_notification_none_if_wrong_version_pk(self, mock_logger):
@@ -110,6 +115,7 @@ def test_webhook_notification_has_content_type_header(self):
'content-type': 'application/json',
'X-Hub-Signature': mock.ANY,
'User-Agent': mock.ANY,
+ 'X-RTD-Event': mock.ANY,
},
timeout=mock.ANY,
)
@@ -148,10 +154,13 @@ def test_send_webhook_custom_payload(self, mock_request):
'extra-data': {
'build_id': '${build.id}',
'build_commit': '${build.commit}',
+ 'build_url': '${build.url}',
+ 'build_docsurl': '${build.docsurl}',
'organization_slug': '${organization.slug}',
'organization_name': '${organization.name}',
'project_slug': '${project.slug}',
'project_name': '${project.name}',
+ 'project_url': '${project.url}',
'version_slug': '${version.slug}',
'version_name': '${version.name}',
'invalid_substitution': '${invalid.substitution}',
@@ -173,10 +182,13 @@ def test_send_webhook_custom_payload(self, mock_request):
'extra-data': {
'build_id': str(self.build.pk),
'build_commit': self.build.commit,
+ 'build_url': f'http://readthedocs.org{self.build.get_absolute_url()}',
+ 'build_docsurl': f'http://test.readthedocs.io/en/1.0',
'organization_name': '',
'organization_slug': '',
'project_name': self.project.name,
'project_slug': self.project.slug,
+ 'project_url': f'http://readthedocs.org{self.project.get_absolute_url()}',
'version_name': self.version.verbose_name,
'version_slug': self.version.slug,
'invalid_substitution': '${invalid.substitution}',
@@ -213,6 +225,7 @@ def test_webhook_headers(self, mock_request):
headers = request.headers
self.assertTrue(headers['User-Agent'].startswith('Read-the-Docs/'))
self.assertEqual(headers['X-Hub-Signature'], signature)
+ self.assertEqual(headers['X-RTD-Event'], WebHookEvent.BUILD_FAILED)
self.assertEqual(webhook.exchanges.all().count(), 1)
@requests_mock.Mocker(kw='mock_request')
diff --git a/readthedocs/templates/projects/webhook_list.html b/readthedocs/templates/projects/webhook_list.html
index 575a67273e6..b7e6c63f373 100644
--- a/readthedocs/templates/projects/webhook_list.html
+++ b/readthedocs/templates/projects/webhook_list.html
@@ -10,8 +10,6 @@
{% block project_edit_content_header %}{% trans "Webhooks" %}{% endblock %}
{% block project_edit_content %}
- Something about webhooks
-