diff --git a/docs/_static/images/webhooks-activity.png b/docs/_static/images/webhooks-activity.png
new file mode 100644
index 00000000000..b8ad92a8446
Binary files /dev/null and b/docs/_static/images/webhooks-activity.png differ
diff --git a/docs/_static/images/webhooks-events.png b/docs/_static/images/webhooks-events.png
new file mode 100644
index 00000000000..26344db2464
Binary files /dev/null and b/docs/_static/images/webhooks-events.png differ
diff --git a/docs/_static/images/webhooks-payload.png b/docs/_static/images/webhooks-payload.png
new file mode 100644
index 00000000000..2534e873fbd
Binary files /dev/null and b/docs/_static/images/webhooks-payload.png differ
diff --git a/docs/_static/images/webhooks-secret.png b/docs/_static/images/webhooks-secret.png
new file mode 100644
index 00000000000..0f14b1cd965
Binary files /dev/null and b/docs/_static/images/webhooks-secret.png differ
diff --git a/docs/build-notifications.rst b/docs/build-notifications.rst
index d57b726c12a..5e8522235e7 100644
--- a/docs/build-notifications.rst
+++ b/docs/build-notifications.rst
@@ -1,13 +1,13 @@
-Enabling Build Notifications
-============================
+Build Notifications and Webhooks
+================================
.. note::
- Currently we don't send notifications when
- a :doc:`build from a pull request fails `.
+ Currently we don't send notifications or trigger webhooks
+ on :doc:`builds from pull requests `.
-Using email
------------
+Email notifications
+-------------------
Read the Docs allows you to configure emails that can be sent on failing builds.
This makes sure you know when your builds have failed.
@@ -20,31 +20,259 @@ Take these steps to enable build notifications using email:
You should now get notified by email when your builds fail!
-Using webhook
--------------
+Build Status Webhooks
+---------------------
Read the Docs can also send webhooks when builds are triggered, successful or failed.
Take these steps to enable build notifications using a webhook:
-* Go to :guilabel:`Admin` > :guilabel:`Notifications` in your project.
-* Fill in the **URL** field under the **New Webhook Notifications** heading
-* Submit the form
+* Go to :guilabel:`Admin` > :guilabel:`Webhooks` in your project.
+* Fill in the **URL** field and select what events will trigger the webhook
+* Modify the payload or leave the default (see below)
+* Click on :guilabel:`Save`
+
+.. figure:: /_static/images/webhooks-events.png
+ :align: center
+ :alt: URL and events for a webhook
+
+ URL and events for a webhook
+
+Every time one of the checked events triggers,
+Read the Docs will send a POST request to your webhook URL.
+The default payload will look like this:
+
+.. code-block:: json
+
+ {
+ "event": "build:triggered",
+ "name": "docs",
+ "slug": "docs",
+ "version": "latest",
+ "commit": "2552bb609ca46865dc36401dee0b1865a0aee52d",
+ "build": "15173336",
+ "start_date": "2021-11-03T16:23:14",
+ "build_url": "https://readthedocs.org/projects/docs/builds/15173336/",
+ "docs_url": "https://docs.readthedocs.io/en/latest/"
+ }
+
+When a webhook is sent, a new entry will be added to the
+"Recent Activity" table. By clicking on each individual entry,
+you will see the server response, the webhook request, and the payload.
+
+.. figure:: /_static/images/webhooks-activity.png
+ :align: center
+ :alt: Activity of a webhook
+
+ Activity of a webhook
+
+Custom payload examples
+~~~~~~~~~~~~~~~~~~~~~~~
+
+You can customize the payload of the webhook to suit your needs,
+as long as it is valid JSON. Below you have a couple of examples,
+and in the following section you will find all the available variables.
+
+.. figure:: /_static/images/webhooks-payload.png
+ :width: 80%
+ :align: center
+ :alt: Custom payload
+
+ Custom payload
-The project name, slug and its details for the build will be sent as POST request to your webhook URL:
+Slack
++++++
.. code-block:: json
+ {
+ "attachments": [
{
- "name": "Read the Docs",
- "slug": "rtd",
- "build": {
- "id": 6321373,
- "commit": "e8dd17a3f1627dd206d721e4be08ae6766fda40",
- "state": "finished",
- "success": false,
- "date": "2017-02-15 20:35:54"
- }
+ "color": "#db3238",
+ "blocks": [
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": "*Read the Docs build failed*"
+ }
+ },
+ {
+ "type": "section",
+ "fields": [
+ {
+ "type": "mrkdwn",
+ "text": "*Project*: <{{ project.url }}|{{ project.name }}>"
+ },
+ {
+ "type": "mrkdwn",
+ "text": "*Version*: {{ version.name }} ({{ build.commit }})"
+ },
+ {
+ "type": "mrkdwn",
+ "text": "*Build*: <{{ build.url }}|{{ build.id }}>"
+ }
+ ]
+ }
+ ]
}
+ ]
+ }
+
+More information on `the Slack Incoming Webhooks documentation `_.
+
+Discord
++++++++
+
+.. code-block:: json
+
+ {
+ "username": "Read the Docs",
+ "content": "Read the Docs build failed",
+ "embeds": [
+ {
+ "title": "Build logs",
+ "url": "{{ build.url }}",
+ "color": 15258703,
+ "fields": [
+ {
+ "name": "*Project*",
+ "value": "{{ project.url }}",
+ "inline": true
+ },
+ {
+ "name": "*Version*",
+ "value": "{{ version.name }} ({{ build.commit }})",
+ "inline": true
+ },
+ {
+ "name": "*Build*",
+ "value": "{{ build.url }}"
+ }
+ ]
+ }
+ ]
+ }
+
+More information on `the Discord webhooks documentation `_.
+
+Variable substitutions reference
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``{{ event }}``
+ Event that triggered the webhook, one of ``build:triggered``, ``build:failed``, or ``build:passed``.
+
+``{{ build.id }}``
+ Build ID.
+
+``{{ build.commit }}``
+ Commit corresponding to the build, if present (otherwise ``""``).
+
+``{{ build.url }}``
+ URL of the build, for example ``https://readthedocs.org/projects/docs/builds/15173336/``.
+
+``{{ build.docs_url }}``
+ URL of the documentation corresponding to the build,
+ for example ``https://docs.readthedocs.io/en/latest/``.
-You should now get notified on your webhook when your builds start and finish (failure/success)!
+``{{ build.start_date }}``
+ Start date of the build (UTC, ISO format), for example ``2021-11-03T16:23:14``.
+
+``{{ organization.name }}``
+ Organization name (Commercial only).
+
+``{{ organization.slug }}``
+ Organization slug (Commercial only).
+
+``{{ project.slug }}``
+ Project slug.
+
+``{{ project.name }}``
+ Project name.
+
+``{{ project.url }}``
+ URL of the project :term:`dashboard`.
+
+``{{ version.slug }}``
+ Version slug.
+
+``{{ version.name }}``
+ Version name.
+
+Validating the payload
+~~~~~~~~~~~~~~~~~~~~~~
+
+After you add a new webhook, Read the Docs will generate a secret key for it
+and uses it to generate a hash signature (HMAC-SHA256) for each payload
+that is included in the ``X-Hub-Signature`` header of the request.
+
+.. figure:: /_static/images/webhooks-secret.png
+ :width: 80%
+ :align: center
+ :alt: Webhook secret
+
+ Webhook secret
+
+We highly recommend using this signature
+to verify that the webhook is coming from Read the Docs.
+To do so, you can add some custom code on your server,
+like this:
+
+.. code-block:: python
+
+ import hashlib
+ import hmac
+ import os
+
+
+ def verify_signature(payload, request_headers):
+ """
+ Verify that the signature of payload is the same as the one coming from request_headers.
+ """
+ digest = hmac.new(
+ key=os.environ["WEBHOOK_SECRET"].encode(),
+ msg=payload.encode(),
+ digestmod=hashlib.sha256,
+ )
+ expected_signature = digest.hexdigest()
+
+ return hmac.compare_digest(
+ request_headers["X-Hub-Signature"].encode(),
+ expected_signature.encode(),
+ )
+
+Legacy webhooks
+~~~~~~~~~~~~~~~
+
+Webhooks created before the custom payloads functionality was added to Read the Docs
+send a payload with the following structure:
+
+.. code-block:: json
+
+ {
+ "name": "Read the Docs",
+ "slug": "rtd",
+ "build": {
+ "id": 6321373,
+ "commit": "e8dd17a3f1627dd206d721e4be08ae6766fda40",
+ "state": "finished",
+ "success": false,
+ "date": "2017-02-15 20:35:54"
+ }
+ }
+
+To migrate to the new webhooks and keep a similar structure,
+you can use this payload:
+
+.. code-block:: json
+
+ {
+ "name": "{{ project.name }}",
+ "slug": "{{ project.slug }}",
+ "build": {
+ "id": "{{ build.id }}",
+ "commit": "{{ build.commit }}",
+ "state": "{{ event }}",
+ "date": "{{ build.start_date }}"
+ }
+ }
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..f2397e07801 100644
--- a/readthedocs/builds/tasks.py
+++ b/readthedocs/builds/tasks.py
@@ -3,9 +3,13 @@
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 +29,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 +457,178 @@ 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` about the `build`.
+
+ 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``
+ protocol = 'http' if settings.DEBUG else 'https'
+ context = {
+ 'version': {
+ 'verbose_name': self.version.verbose_name,
+ },
+ 'project': {
+ 'name': self.project.name,
+ },
+ 'build': {
+ 'pk': self.build.pk,
+ 'error': self.build.error,
+ },
+ 'build_url': '{}://{}{}'.format(
+ protocol,
+ settings.PRODUCTION_DOMAIN,
+ self.build.get_absolute_url(),
+ ),
+ 'unsubscribe_url': '{}://{}{}'.format(
+ protocol,
+ 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-SHA256,
+ 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__} ({settings.PRODUCTION_DOMAIN})',
+ 'X-RTD-Event': self.event,
+ }
+ 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 b1154470085..3d34929e8d0 100644
--- a/readthedocs/projects/admin.py
+++ b/readthedocs/projects/admin.py
@@ -21,12 +21,13 @@
EmailHook,
EnvironmentVariable,
Feature,
- HTTPHeader,
HTMLFile,
+ HTTPHeader,
ImportedFile,
Project,
ProjectRelationship,
WebHook,
+ WebHookEvent,
)
from .notifications import (
DeprecatedBuildWebhookNotification,
@@ -480,4 +481,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..63e2a88837d 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,49 @@ 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.id }}',
+ 'start_date': '{{ build.start_date }}',
+ 'build_url': '{{ build.url }}',
+ 'docs_url': '{{ build.docs_url }}',
+ }, 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/0083_init_generic_webhooks.py b/readthedocs/projects/migrations/0083_init_generic_webhooks.py
new file mode 100644
index 00000000000..3f1d6beb89a
--- /dev/null
+++ b/readthedocs/projects/migrations/0083_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', '0082_add_extra_history_fields'),
+ ]
+
+ 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', null=True),
+ ),
+ 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', null=True),
+ ),
+ 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/0084_create_webhook_events.py b/readthedocs/projects/migrations/0084_create_webhook_events.py
new file mode 100644
index 00000000000..738b68fd4c0
--- /dev/null
+++ b/readthedocs/projects/migrations/0084_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):
+ """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)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0083_init_generic_webhooks'),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards_func),
+ ]
diff --git a/readthedocs/projects/migrations/0085_subscribe_old_webhooks_to_events.py b/readthedocs/projects/migrations/0085_subscribe_old_webhooks_to_events.py
new file mode 100644
index 00000000000..36b9d9819df
--- /dev/null
+++ b/readthedocs/projects/migrations/0085_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', '0084_create_webhook_events'),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards_func),
+ ]
diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py
index 96f3fb1e006..5ee7744c2da 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,14 +12,19 @@
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
-from django_extensions.db.fields import CreationDateTimeField
+from django_extensions.db.fields import (
+ CreationDateTimeField,
+ ModificationDateTimeField,
+)
from django_extensions.db.models import TimeStampedModel
from taggit.managers import TaggableManager
@@ -1450,7 +1457,20 @@ 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,
+ )
+ modified = ModificationDateTimeField(
+ _('modified'),
+ null=True,
+ blank=True,
+ )
+
project = models.ForeignKey(
Project,
related_name='%(class)s_notifications',
@@ -1469,14 +1489,137 @@ 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.', # noqa
+ ),
+ 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.
+
+ Placeholders are in the ``{{ foo }}`` or ``{{foo}}`` format.
+ """
+ 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
+
+ # 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 = project.get_docs_url(
+ version_slug=version.slug,
+ external=version.is_external,
+ )
+
+ # Remove timezone and microseconds from the date,
+ # so it's more readable.
+ start_date = build.date.replace(
+ tzinfo=None,
+ microsecond=0
+ ).isoformat()
+
+ substitutions = {
+ 'event': event,
+ 'build.id': build.id,
+ 'build.commit': commit,
+ 'build.url': build_url,
+ 'build.docs_url': build_docsurl,
+ 'build.start_date': start_date,
+ '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,
+ }
+ payload = self.payload
+ # Small protection for DDoS.
+ max_substitutions = 99
+ for substitution, value in substitutions.items():
+ # Replace {{ foo }}.
+ payload = payload.replace(f'{{{{ {substitution} }}}}', str(value), max_substitutions)
+ # Replace {{foo}}.
+ payload = payload.replace(f'{{{{{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.sha256,
+ )
+ 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 26215e12de7..ff24ede494a 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
@@ -823,7 +828,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(
@@ -834,7 +843,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()
@@ -1321,12 +1334,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."""
@@ -1652,123 +1669,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 aa205528509..f87c80196b4 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,
HttpResponse,
@@ -18,7 +18,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,
@@ -40,7 +40,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
@@ -517,7 +516,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()
@@ -526,37 +524,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
@@ -582,6 +579,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 0a6d5c34b43..b8b1f8e95d9 100644
--- a/readthedocs/rtd_tests/base.py
+++ b/readthedocs/rtd_tests/base.py
@@ -65,7 +65,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..3ef8c3a0e58 100644
--- a/readthedocs/rtd_tests/tests/test_build_notifications.py
+++ b/readthedocs/rtd_tests/tests/test_build_notifications.py
@@ -1,62 +1,96 @@
"""Notifications sent after build is completed."""
-
+import hashlib
+import hmac
+from django.utils import timezone
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.test import TestCase, override_settings
+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,
+)
+@override_settings(
+ PRODUCTION_DOMAIN='readthedocs.org',
+ PUBLIC_DOMAIN='readthedocs.io',
+ USE_SUBDOMAIN=True,
+)
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, slug='test', language='en')
+ self.version = get(Version, project=self.project, slug='1.0')
+ self.build = get(Build, version=self.version, commit='abc1234567890')
- @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 +102,235 @@ 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,
+ 'X-RTD-Event': 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)
+
+ @requests_mock.Mocker(kw='mock_request')
+ def test_send_webhook_custom_payload(self, mock_request):
+ self.build.date = timezone.datetime(
+ year=2021, month=3, day=15, hour=15, minute=30, second=4,
+ )
+ self.build.save()
+ 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}}',
+ 'build_url': '{{ build.url }}',
+ 'build_docsurl': '{{ build.docs_url }}',
+ 'build_start_date': '{{ build.start_date }}',
+ '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 }}',
+ }
+ }),
+ )
+ 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,
+ 'build_url': f'https://readthedocs.org{self.build.get_absolute_url()}',
+ 'build_docsurl': 'http://test.readthedocs.io/en/1.0/',
+ 'build_start_date': '2021-03-15T15:30:04',
+ 'organization_name': '',
+ 'organization_slug': '',
+ 'project_name': self.project.name,
+ 'project_slug': self.project.slug,
+ 'project_url': f'https://readthedocs.org{self.project.get_absolute_url()}',
+ 'version_name': self.version.verbose_name,
+ 'version_slug': self.version.slug,
+ 'invalid_substitution': '{{ invalid.substitution }}',
+ },
+ }
+ )
+ self.assertEqual(webhook.exchanges.all().count(), 1)
- 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_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.sha256,
+ ).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(headers['X-RTD-Event'], WebHookEvent.BUILD_FAILED)
+ 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 +339,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 bbad4d2e07f..ea2849ddc88 100644
--- a/readthedocs/rtd_tests/tests/test_project_views.py
+++ b/readthedocs/rtd_tests/tests/test_project_views.py
@@ -1,12 +1,10 @@
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.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from django.views.generic.base import ContextMixin
@@ -16,9 +14,10 @@
from readthedocs.builds.models import Build, Version
from readthedocs.integrations.models import GenericAPIWebhook, GitHubWebhook
from readthedocs.oauth.models import RemoteRepository, RemoteRepositoryRelation
+from readthedocs.organizations.models import Organization
from readthedocs.projects.constants import PUBLIC
from readthedocs.projects.exceptions import ProjectSpamError
-from readthedocs.projects.models import Domain, Project
+from readthedocs.projects.models import Domain, Project, WebHook, WebHookEvent
from readthedocs.projects.views.mixins import ProjectRelationMixin
from readthedocs.projects.views.private import ImportWizardView
from readthedocs.projects.views.public import ProjectBadgeView
@@ -667,3 +666,65 @@ def test_project_filtering_work_with_tags_with_space_in_name(self):
pip.tags.add('tag with space')
response = self.client.get('/projects/tags/tag-with-space/')
self.assertContains(response, '"/projects/pip/"')
+
+
+@override_settings(RTD_ALLOW_ORGANIZATIONS=False)
+class TestWebhooksViews(TestCase):
+
+ def setUp(self):
+ self.user = get(User)
+ self.project = get(Project, slug='test', users=[self.user])
+ self.version = get(Version, slug='1.0', project=self.project)
+ self.webhook = get(WebHook, project=self.project)
+ self.client.force_login(self.user)
+
+ def test_list(self):
+ resp = self.client.get(
+ reverse('projects_webhooks', args=[self.project.slug]),
+ )
+ self.assertEqual(resp.status_code, 200)
+ queryset = resp.context['object_list']
+ self.assertEqual(queryset.count(), 1)
+ self.assertEqual(queryset.first(), self.webhook)
+
+ def test_create(self):
+ self.assertEqual(self.project.webhook_notifications.all().count(), 1)
+ resp = self.client.post(
+ reverse('projects_webhooks_create', args=[self.project.slug]),
+ data = {
+ 'url': 'http://www.example.com/',
+ 'payload': '{}',
+ 'events': [WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
+ },
+ )
+ self.assertEqual(resp.status_code, 302)
+ self.assertEqual(self.project.webhook_notifications.all().count(), 2)
+
+ def test_update(self):
+ self.assertEqual(self.project.webhook_notifications.all().count(), 1)
+ self.client.post(
+ reverse('projects_webhooks_edit', args=[self.project.slug, self.webhook.pk]),
+ data = {
+ 'url': 'http://www.example.com/new',
+ 'payload': '{}',
+ 'events': [WebHookEvent.objects.get(name=WebHookEvent.BUILD_FAILED).id],
+ },
+ )
+ self.webhook.refresh_from_db()
+ self.assertEqual(self.webhook.url, 'http://www.example.com/new')
+ self.assertEqual(self.project.webhook_notifications.all().count(), 1)
+
+ def test_delete(self):
+ self.assertEqual(self.project.webhook_notifications.all().count(), 1)
+ self.client.post(
+ reverse('projects_webhooks_delete', args=[self.project.slug, self.webhook.pk]),
+ )
+ self.assertEqual(self.project.webhook_notifications.all().count(), 0)
+
+
+@override_settings(RTD_ALLOW_ORGANIZATIONS=True)
+class TestWebhooksViewsWithOrganizations(TestWebhooksViews):
+
+ def setUp(self):
+ super().setUp()
+ self.organization = get(Organization, owners=[self.user], projects=[self.project])
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 %}
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..f51a8705d6d
--- /dev/null
+++ b/readthedocs/templates/projects/webhook_form.html
@@ -0,0 +1,71 @@
+{% 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 with docs_url="https://readthedocs.io/page/build-notifications.html#webhooks" %}
+ We’ll send a POST
request to the URL with the JSON payload below on the selected events.
+ Check our docs for more information.
+ {% 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/build-notifications.html#legacy-webhooks" %}
+ 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..b7e6c63f373
--- /dev/null
+++ b/readthedocs/templates/projects/webhook_list.html
@@ -0,0 +1,54 @@
+{% 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 %}
+
+
+
+{% endblock %}