diff --git a/hc/accounts/views.py b/hc/accounts/views.py index 1b751537..7b667434 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -10,7 +10,7 @@ from django.shortcuts import redirect, render from hc.accounts.forms import EmailForm from hc.api.models import Channel, Check -from hc.lib.emails import send +from hc.lib import emails def _make_user(email): @@ -51,7 +51,7 @@ def _send_login_link(user): login_link = settings.SITE_ROOT + login_link ctx = {"login_link": login_link} - send(user.email, "emails/login", ctx) + emails.login(user.email, ctx) def login(request): diff --git a/hc/api/admin.py b/hc/api/admin.py index 97caf282..b4fb767a 100644 --- a/hc/api/admin.py +++ b/hc/api/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from hc.api.models import Channel, Check, Ping +from hc.api.models import Channel, Check, Notification, Ping class OwnershipListFilter(admin.SimpleListFilter): @@ -61,4 +61,32 @@ class PingsAdmin(admin.ModelAdmin): @admin.register(Channel) class ChannelsAdmin(admin.ModelAdmin): list_select_related = ("user", ) - list_display = ("id", "code", "user", "kind", "value") + list_display = ("id", "code", "user", "formatted_kind", "value") + + def formatted_kind(self, obj): + if obj.kind == "pd": + return "PagerDuty" + elif obj.kind == "webhook": + return "Webhook" + elif obj.kind == "email" and obj.email_verified: + return "Email" + elif obj.kind == "email" and not obj.email_verified: + return "Email (unverified)" + else: + raise NotImplementedError("Bad channel kind: %s" % obj.kind) + + +@admin.register(Notification) +class NotificationsAdmin(admin.ModelAdmin): + list_select_related = ("owner", "channel") + list_display = ("id", "created", "check_status", "check_name", + "channel_kind", "channel_value", "status") + + def check_name(self, obj): + return obj.owner.name_then_code() + + def channel_kind(self, obj): + return obj.channel.kind + + def channel_value(self, obj): + return obj.channel.value diff --git a/hc/api/management/commands/makechannels.py b/hc/api/management/commands/makechannels.py new file mode 100644 index 00000000..20d2afa4 --- /dev/null +++ b/hc/api/management/commands/makechannels.py @@ -0,0 +1,32 @@ +import sys + +from django.core.management.base import BaseCommand + +from django.contrib.auth.models import User +from hc.api.models import Channel, Check + + +def _log(message): + sys.stdout.write(message) + sys.stdout.flush() + + +class Command(BaseCommand): + help = 'Sends UP/DOWN email alerts' + + def handle(self, *args, **options): + + for user in User.objects.all(): + q = Channel.objects.filter(user=user) + q = q.filter(kind="email", email_verified=True, value=user.email) + if q.count() > 0: + continue + + print("Creating default channel for %s" % user.email) + channel = Channel(user=user) + channel.kind = "email" + channel.value = user.email + channel.email_verified = True + channel.save() + + channel.checks.add(*Check.objects.filter(user=user)) diff --git a/hc/api/management/commands/sendalerts.py b/hc/api/management/commands/sendalerts.py index 066578ab..c5ed4dec 100644 --- a/hc/api/management/commands/sendalerts.py +++ b/hc/api/management/commands/sendalerts.py @@ -27,7 +27,7 @@ class Command(BaseCommand): for check in query: check.status = "down" - _log("\nSending email about going down for %s\n" % check.code) + _log("\nSending notification(s) about going down for %s\n" % check.code) check.send_alert() ticks = 0 @@ -42,7 +42,7 @@ class Command(BaseCommand): for check in query: check.status = "up" - _log("\nSending email about going up for %s\n" % check.code) + _log("\nSending notification(s) about going up for %s\n" % check.code) check.send_alert() ticks = 0 diff --git a/hc/api/migrations/0011_notification.py b/hc/api/migrations/0011_notification.py new file mode 100644 index 00000000..21a079c4 --- /dev/null +++ b/hc/api/migrations/0011_notification.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0010_channel'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), + ('check_status', models.CharField(max_length=6)), + ('created', models.DateTimeField(auto_now_add=True)), + ('status', models.IntegerField(default=0)), + ('channel', models.ForeignKey(to='api.Channel')), + ('owner', models.ForeignKey(to='api.Check')), + ], + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index acd43b24..71c39584 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -1,14 +1,19 @@ # coding: utf-8 from datetime import timedelta as td +import hashlib +import json import uuid from django.conf import settings +from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.db import models from django.utils import timezone +import requests + +from hc.lib import emails -from hc.lib.emails import send STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New")) DEFAULT_TIMEOUT = td(days=1) @@ -31,6 +36,12 @@ class Check(models.Model): def __str__(self): return "Check(%s)" % self.code + def name_then_code(self): + if self.name: + return self.name + + return str(self.code) + def url(self): return settings.PING_ENDPOINT + str(self.code) @@ -38,18 +49,12 @@ class Check(models.Model): return "%s@%s" % (self.code, settings.PING_EMAIL_DOMAIN) def send_alert(self): - ctx = { - "check": self, - "checks": self.user.check_set.order_by("created"), - "now": timezone.now() - - } - - if self.status in ("up", "down"): - send(self.user.email, "emails/alert", ctx) - else: + if self.status not in ("up", "down"): raise NotImplemented("Unexpected status: %s" % self.status) + for channel in self.channel_set.all(): + channel.notify(self) + def get_status(self): if self.status == "new": return "new" @@ -65,8 +70,9 @@ class Check(models.Model): return "down" def assign_all_channels(self): - channels = Channel.objects.filter(user=self.user) - self.channel_set.add(*channels) + if self.user: + channels = Channel.objects.filter(user=self.user) + self.channel_set.add(*channels) class Ping(models.Model): @@ -87,3 +93,62 @@ class Channel(models.Model): value = models.CharField(max_length=200, blank=True) email_verified = models.BooleanField(default=False) checks = models.ManyToManyField(Check) + + def make_token(self): + seed = "%s%s" % (self.code, settings.SECRET_KEY) + seed = seed.encode("utf8") + return hashlib.sha1(seed).hexdigest() + + def send_verify_link(self): + args = [self.code, self.make_token()] + verify_link = reverse("hc-verify-email", args=args) + verify_link = settings.SITE_ROOT + verify_link + emails.verify_email(self.value, {"verify_link": verify_link}) + + def notify(self, check): + n = Notification(owner=check, channel=self) + n.check_status = check.status + + if self.kind == "email" and self.email_verified: + ctx = { + "check": check, + "checks": self.user.check_set.order_by("created"), + "now": timezone.now() + } + emails.alert(self.value, ctx) + n.save() + elif self.kind == "webhook" and self.status == "down": + r = requests.get(self.value) + n.status = r.status_code + n.save() + elif self.kind == "pd": + if check.status == "down": + event_type = "trigger" + description = "%s is DOWN" % check.name_then_code() + else: + event_type = "resolve" + description = "%s received a ping and is now UP" % \ + check.name_then_code() + + payload = { + "service_key": self.value, + "incident_key": str(check.code), + "event_type": event_type, + "description": description, + "client": "healthchecks.io", + "client_url": settings.SITE_ROOT + } + + url = "https://events.pagerduty.com/generic/2010-04-15/create_event.json" + r = requests.post(url, data=json.dumps(payload)) + + n.status = r.status_code + n.save() + + +class Notification(models.Model): + owner = models.ForeignKey(Check) + check_status = models.CharField(max_length=6) + channel = models.ForeignKey(Channel) + created = models.DateTimeField(auto_now_add=True) + status = models.IntegerField(default=0) diff --git a/hc/front/urls.py b/hc/front/urls.py index 01cbd12f..623bf5af 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -16,4 +16,7 @@ urlpatterns = [ url(r'^channels/$', views.channels, name="hc-channels"), url(r'^channels/add/$', views.add_channel, name="hc-add-channel"), url(r'^channels/([\w-]+)/checks/$', views.channel_checks, name="hc-channel-checks"), + url(r'^channels/([\w-]+)/verify/([\w-]+)/$', + views.verify_email, name="hc-verify-email"), + ] diff --git a/hc/front/views.py b/hc/front/views.py index 4d5266b0..0eff3545 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -217,6 +217,9 @@ def add_channel(request): checks = Check.objects.filter(user=request.user) channel.checks.add(*checks) + if channel.kind == "email": + channel.send_verify_link() + return redirect("hc-channels") @@ -235,3 +238,14 @@ def channel_checks(request, code): } return render(request, "front/channel_checks.html", ctx) + + +@uuid_or_400 +def verify_email(request, code, token): + channel = Channel.objects.get(code=code) + if channel.make_token() == token: + channel.email_verified = True + channel.save() + return render(request, "front/verify_email_success.html") + + return render(request, "bad_link.html") diff --git a/hc/lib/emails.py b/hc/lib/emails.py index 092c0149..acd76a39 100644 --- a/hc/lib/emails.py +++ b/hc/lib/emails.py @@ -1,18 +1,16 @@ -from django.conf import settings -from django.core.mail import send_mail -from django.template.loader import render_to_string +from djmail.template_mail import InlineCSSTemplateMail -def send(to, template_directory, ctx): - """ Send HTML email using Mandrill. +def login(to, ctx): + o = InlineCSSTemplateMail("login") + o.send(to, ctx) - Expect template_directory to be a path containing - - subject.txt - - body.html - """ +def alert(to, ctx): + o = InlineCSSTemplateMail("alert") + o.send(to, ctx) - from_email = settings.DEFAULT_FROM_EMAIL - subject = render_to_string("%s/subject.txt" % template_directory, ctx) - body = render_to_string("%s/body.html" % template_directory, ctx) - send_mail(subject, "", from_email, [to], html_message=body) + +def verify_email(to, ctx): + o = InlineCSSTemplateMail("verify-email") + o.send(to, ctx) diff --git a/hc/settings.py b/hc/settings.py index 188ab161..7dd7a4ba 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -31,7 +31,7 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.staticfiles', 'compressor', - 'djrill', + 'djmail', 'hc.accounts', 'hc.api', @@ -123,12 +123,9 @@ STATICFILES_FINDERS = ( ) COMPRESS_OFFLINE = True - -EMAIL_BACKEND = "djrill.mail.backends.djrill.DjrillBackend" +EMAIL_BACKEND = "djmail.backends.default.EmailBackend" try: from local_settings import * except ImportError as e: warnings.warn("local_settings.py not found, using defaults") - -print ("db engine: %s" % DATABASES["default"]["ENGINE"]) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index df6db7ea..b60f573c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ Django==1.8.2 django_compressor psycopg2==2.6 -djrill -pygments \ No newline at end of file +djmail +premailer +pygments +requests \ No newline at end of file diff --git a/templates/emails/alert/body.html b/templates/emails/alert-body-html.html similarity index 100% rename from templates/emails/alert/body.html rename to templates/emails/alert-body-html.html diff --git a/templates/emails/alert/subject.txt b/templates/emails/alert-subject.html similarity index 100% rename from templates/emails/alert/subject.txt rename to templates/emails/alert-subject.html diff --git a/templates/emails/login/body.html b/templates/emails/login-body-html.html similarity index 100% rename from templates/emails/login/body.html rename to templates/emails/login-body-html.html diff --git a/templates/emails/login/subject.txt b/templates/emails/login-subject.html similarity index 100% rename from templates/emails/login/subject.txt rename to templates/emails/login-subject.html diff --git a/templates/emails/verify-email-body-html.html b/templates/emails/verify-email-body-html.html new file mode 100644 index 00000000..f8ba0ffa --- /dev/null +++ b/templates/emails/verify-email-body-html.html @@ -0,0 +1,11 @@ +

Hello,

+ +

To start receiving healthchecks.io notification to this address, +please click the link below:

+

{{ verify_link }}

+ +

+ --
+ Regards,
+ healthchecks.io +

diff --git a/templates/emails/verify-email-subject.html b/templates/emails/verify-email-subject.html new file mode 100644 index 00000000..6c7679a4 --- /dev/null +++ b/templates/emails/verify-email-subject.html @@ -0,0 +1 @@ +Verify email address on healthchecks.io diff --git a/templates/front/verify_email_success.html b/templates/front/verify_email_success.html new file mode 100644 index 00000000..4a303360 --- /dev/null +++ b/templates/front/verify_email_success.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Email Address Verified!

+
+

+ Success! You've verified this email + address, and it will now receive + healthchecks.io notifications. +

+ +
+
+
+ +{% endblock %}