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