diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e565f2..baa905c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ### Improvements - In monthly reports, no downtime stats for the current month (month has just started) +- Add Microsoft Teams integration (#135) ### Bug Fixes - On mobile, "My Checks" page, always show the gear (Details) button (#286) diff --git a/hc/api/models.py b/hc/api/models.py index 9f19b854..59c50308 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -45,6 +45,7 @@ CHANNEL_KINDS = ( ("whatsapp", "WhatsApp"), ("apprise", "Apprise"), ("mattermost", "Mattermost"), + ("msteams", "Microsoft Teams"), ) PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"} @@ -410,6 +411,8 @@ class Channel(models.Model): return transports.WhatsApp(self) elif self.kind == "apprise": return transports.Apprise(self) + elif self.kind == "msteams": + return transports.MsTeams(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index 1b7361a0..4f9fa704 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -699,3 +699,15 @@ class NotifyTestCase(BaseTestCase): with self.assertRaises(NotImplementedError): self.channel.notify(self.check) + + @patch("hc.api.transports.requests.request") + def test_mesteams(self, mock_post): + self._setup_data("msteams", "http://example.com/webhook") + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + assert Notification.objects.count() == 1 + + args, kwargs = mock_post.call_args + payload = kwargs["json"] + self.assertEqual(payload["@type"], "MessageCard") diff --git a/hc/api/transports.py b/hc/api/transports.py index 4c4e177d..feac54ff 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -498,3 +498,10 @@ class Apprise(HttpTransport): if not a.notify(body=body, title=title, notify_type=notify_type) else None ) + + +class MsTeams(HttpTransport): + def notify(self, check): + text = tmpl("msteams_message.json", check=check) + payload = json.loads(text) + return self.post(self.channel.value, json=payload) diff --git a/hc/front/tests/test_add_msteams.py b/hc/front/tests/test_add_msteams.py new file mode 100644 index 00000000..441d43bf --- /dev/null +++ b/hc/front/tests/test_add_msteams.py @@ -0,0 +1,21 @@ +from hc.api.models import Channel +from hc.test import BaseTestCase + + +class AddMsTeamsTestCase(BaseTestCase): + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get("/integrations/add_msteams/") + self.assertContains(r, "Integration Settings", status_code=200) + + def test_it_works(self): + form = {"value": "https://example.com/foo"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post("/integrations/add_msteams/", form) + self.assertRedirects(r, "/integrations/") + + c = Channel.objects.get() + self.assertEqual(c.kind, "msteams") + self.assertEqual(c.value, "https://example.com/foo") + self.assertEqual(c.project, self.project) diff --git a/hc/front/urls.py b/hc/front/urls.py index a74f3183..8b34888a 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -46,6 +46,7 @@ channel_urls = [ path("add_trello/settings/", views.trello_settings, name="hc-trello-settings"), path("add_matrix/", views.add_matrix, name="hc-add-matrix"), path("add_apprise/", views.add_apprise, name="hc-add-apprise"), + path("add_msteams/", views.add_msteams, name="hc-add-msteams"), path("/checks/", views.channel_checks, name="hc-channel-checks"), path("/name/", views.update_channel_name, name="hc-channel-name"), path("/test/", views.send_test_notification, name="hc-channel-test"), diff --git a/hc/front/views.py b/hc/front/views.py index 5dc2184c..1d693274 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -1415,3 +1415,21 @@ def trello_settings(request): r = requests.get(url) ctx = {"token": token, "data": r.json()} return render(request, "integrations/trello_settings.html", ctx) + + +@login_required +def add_msteams(request): + if request.method == "POST": + form = AddUrlForm(request.POST) + if form.is_valid(): + channel = Channel(project=request.project, kind="msteams") + channel.value = form.cleaned_data["value"] + channel.save() + + channel.assign_all_checks() + return redirect("hc-channels") + else: + form = AddUrlForm() + + ctx = {"page": "channels", "project": request.project, "form": form} + return render(request, "integrations/add_msteams.html", ctx) diff --git a/static/css/icomoon.css b/static/css/icomoon.css index 5c0262b9..d1e78513 100644 --- a/static/css/icomoon.css +++ b/static/css/icomoon.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?q2glnk'); - src: url('../fonts/icomoon.eot?q2glnk#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?q2glnk') format('truetype'), - url('../fonts/icomoon.woff?q2glnk') format('woff'), - url('../fonts/icomoon.svg?q2glnk#icomoon') format('svg'); + src: url('../fonts/icomoon.eot?tg9zp8'); + src: url('../fonts/icomoon.eot?tg9zp8#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?tg9zp8') format('truetype'), + url('../fonts/icomoon.woff?tg9zp8') format('woff'), + url('../fonts/icomoon.svg?tg9zp8#icomoon') format('svg'); font-weight: normal; font-style: normal; } @@ -24,6 +24,10 @@ -moz-osx-font-smoothing: grayscale; } +.icon-msteams:before { + content: "\e916"; + color: #4e56be; +} .icon-opsgenie:before { content: "\e910"; color: #2684ff; diff --git a/static/fonts/icomoon.eot b/static/fonts/icomoon.eot index d4942b54..9e6da78e 100644 Binary files a/static/fonts/icomoon.eot and b/static/fonts/icomoon.eot differ diff --git a/static/fonts/icomoon.svg b/static/fonts/icomoon.svg index 17105232..51782c0e 100644 --- a/static/fonts/icomoon.svg +++ b/static/fonts/icomoon.svg @@ -40,4 +40,5 @@ + \ No newline at end of file diff --git a/static/fonts/icomoon.ttf b/static/fonts/icomoon.ttf index 02f24a9f..c82d8390 100644 Binary files a/static/fonts/icomoon.ttf and b/static/fonts/icomoon.ttf differ diff --git a/static/fonts/icomoon.woff b/static/fonts/icomoon.woff index 47b5b77e..ea44191b 100644 Binary files a/static/fonts/icomoon.woff and b/static/fonts/icomoon.woff differ diff --git a/static/img/integrations/msteams.png b/static/img/integrations/msteams.png new file mode 100644 index 00000000..3b534c64 Binary files /dev/null and b/static/img/integrations/msteams.png differ diff --git a/static/img/integrations/setup_msteams_1.png b/static/img/integrations/setup_msteams_1.png new file mode 100644 index 00000000..8820945b Binary files /dev/null and b/static/img/integrations/setup_msteams_1.png differ diff --git a/static/img/integrations/setup_msteams_2.png b/static/img/integrations/setup_msteams_2.png new file mode 100644 index 00000000..4265841c Binary files /dev/null and b/static/img/integrations/setup_msteams_2.png differ diff --git a/static/img/integrations/setup_msteams_3.png b/static/img/integrations/setup_msteams_3.png new file mode 100644 index 00000000..76835b42 Binary files /dev/null and b/static/img/integrations/setup_msteams_3.png differ diff --git a/static/img/integrations/setup_msteams_4.png b/static/img/integrations/setup_msteams_4.png new file mode 100644 index 00000000..d2f04a86 Binary files /dev/null and b/static/img/integrations/setup_msteams_4.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index 5cc4635c..ca130b0e 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -102,6 +102,8 @@ {% endif %} {% elif ch.kind == "mattermost" %} Mattermost + {% elif ch.kind == "msteams" %} + Microsoft Teams {% else %} {{ ch.kind }} {% endif %} @@ -251,6 +253,16 @@ Add Integration +
  • + Microsoft Teams + +

    Microsoft Teams

    +

    Chat and collaboration platform for Microsoft Office 365 customers.

    + + Add Integration +
  • +
  • OpsGenie icon diff --git a/templates/integrations/add_msteams.html b/templates/integrations/add_msteams.html new file mode 100644 index 00000000..dc29cdcf --- /dev/null +++ b/templates/integrations/add_msteams.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} +{% load humanize static hc_extras %} + +{% block title %}Add Microsoft Teams - {% site_name %}{% endblock %} + + +{% block content %} +
    +
    +

    Microsoft Teams

    + +

    If your team uses Microsoft Teams, + you can set up {% site_name %} to post status updates directly to an appropriate + Microsoft Teams channel.

    + +

    Setup Guide

    + +
    +
    + 1 +

    + Log into your Microsoft Teams account, click the Apps tab. +

    +

    + Search for the Incoming Webhook connector, and add it. +

    +
    +
    +
    + + Add the Incoming Webhook connector +
    +
    +
    + +
    +
    + 2 +

    + Select the channel where you want {% site_name %} to post + notifications. +

    +
    +
    + Select the channel +
    +
    + +
    +
    + 3 +

    + Give the connector a descriptive name. +

    +

    + Optionally, upload an icon + (feel free to use this one). +

    +

    + Click on Create. +

    +
    +
    + Create the connector +
    +
    + +
    +
    + 4 +

    + Copy the displayed webhook URL and paste it + in the form below. Save the integration, and you are done! +

    +
    +
    +
    + + Copy the Webhook URL +
    +
    +
    + +

    Integration Settings

    + +
    + {% csrf_token %} +
    + +
    + + + {% if form.value.errors %} +
    + {{ form.value.errors|join:"" }} +
    + {% endif %} +
    +
    +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/templates/integrations/msteams_message.json b/templates/integrations/msteams_message.json new file mode 100644 index 00000000..796b16b8 --- /dev/null +++ b/templates/integrations/msteams_message.json @@ -0,0 +1,55 @@ +{% load hc_extras humanize %} +{ + "@type": "MessageCard", + "@context": "https://schema.org/extensions", + "themeColor": "{% if check.status == "up" %}5cb85c{% endif %}{% if check.status == "down" %}d9534f{% endif %}", + "text": "“{{ check.name_then_code|escapejs }}” is {{ check.status|upper }}.", + "sections": [ + { + "facts": [ + {% if check.tags_list %} + { + "name": "Tags:", + "value": "{% for tag in check.tags_list %}`{{ tag|escapejs }}` {% endfor %}" + }, + {% endif %} + {% if check.kind == "simple" %} + { + "name": "Period:", + "value": "{{ check.timeout|hc_duration }}" + }, + {% elif check.kind == "cron" %} + { + "name": "Schedule:", + "value": "{{ check.schedule|escapejs }}" + }, + {% endif %} + { + "name": "Last Ping:", + {% if check.last_ping %} + "value": "{{ check.last_ping|naturaltime }}" + {% else %} + "value": "Never" + {% endif %} + }, + { + "name": "Total Pings:", + "value": "{{ check.n_pings }}" + } + ], + "text": "{{ check.desc|escapejs }}" + } + ], + "potentialAction": [ + { + "@type": "OpenUri", + "name": "View in {% site_name %}", + "targets": [ + { + "os": "default", + "uri": "{{ check.details_url }}" + } + ] + } + ] +}