No known key found for this signature in database
GPG Key ID: E28D7679E9A9EDE2
23 changed files with 588 additions and 23 deletions
-
1CHANGELOG.md
-
42hc/api/models.py
-
100hc/api/tests/test_notify_ntfy.py
-
30hc/api/transports.py
-
11hc/front/forms.py
-
72hc/front/tests/test_add_ntfy.py
-
61hc/front/tests/test_edit_ntfy.py
-
1hc/front/urls.py
-
39hc/front/views.py
-
3static/css/add_pushover.css
-
5static/css/base.css
-
4static/css/channels.css
-
15static/css/icomoon.css
-
5static/fonts/icomoon.svg
-
BINstatic/fonts/icomoon.ttf
-
BINstatic/fonts/icomoon.woff
-
BINstatic/img/integrations/ntfy.png
-
1templates/base.html
-
14templates/front/channels.html
-
26templates/integrations/add_pushover.html
-
159templates/integrations/ntfy_form.html
-
21templates/integrations/ntfy_message.html
-
1templates/integrations/ntfy_title.html
@ -0,0 +1,100 @@ |
|||
# coding: utf-8 |
|||
|
|||
from __future__ import annotations |
|||
|
|||
import json |
|||
from datetime import timedelta as td |
|||
from unittest.mock import patch |
|||
|
|||
from django.utils.timezone import now |
|||
|
|||
from hc.api.models import Channel, Check, Notification |
|||
from hc.test import BaseTestCase |
|||
|
|||
|
|||
class NotifyNtfyTestCase(BaseTestCase): |
|||
def setUp(self): |
|||
super().setUp() |
|||
|
|||
self.check = Check(project=self.project) |
|||
self.check.name = "Foo" |
|||
self.check.status = "down" |
|||
self.check.last_ping = now() - td(minutes=61) |
|||
self.check.save() |
|||
|
|||
self.channel = Channel(project=self.project) |
|||
self.channel.kind = "ntfy" |
|||
self.channel.value = json.dumps( |
|||
{ |
|||
"url": "https://example.org", |
|||
"topic": "foo", |
|||
"priority": 5, |
|||
"priority_up": 1, |
|||
} |
|||
) |
|||
self.channel.save() |
|||
self.channel.checks.add(self.check) |
|||
|
|||
@patch("hc.api.transports.curl.request") |
|||
def test_it_works(self, mock_post): |
|||
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["title"], "Foo is DOWN") |
|||
self.assertEqual(payload["actions"][0]["url"], self.check.cloaked_url()) |
|||
self.assertNotIn("All the other checks are up.", payload["message"]) |
|||
|
|||
@patch("hc.api.transports.curl.request") |
|||
def test_it_shows_all_other_checks_up_note(self, mock_post): |
|||
mock_post.return_value.status_code = 200 |
|||
|
|||
other = Check(project=self.project) |
|||
other.name = "Foobar" |
|||
other.status = "up" |
|||
other.last_ping = now() - td(minutes=61) |
|||
other.save() |
|||
|
|||
self.channel.notify(self.check) |
|||
|
|||
args, kwargs = mock_post.call_args |
|||
payload = kwargs["json"] |
|||
self.assertIn("All the other checks are up.", payload["message"]) |
|||
|
|||
@patch("hc.api.transports.curl.request") |
|||
def test_it_lists_other_down_checks(self, mock_post): |
|||
mock_post.return_value.status_code = 200 |
|||
|
|||
other = Check(project=self.project) |
|||
other.name = "Foobar" |
|||
other.status = "down" |
|||
other.last_ping = now() - td(minutes=61) |
|||
other.save() |
|||
|
|||
self.channel.notify(self.check) |
|||
|
|||
args, kwargs = mock_post.call_args |
|||
payload = kwargs["json"] |
|||
self.assertIn("The following checks are also down", payload["message"]) |
|||
self.assertIn("Foobar", payload["message"]) |
|||
|
|||
@patch("hc.api.transports.curl.request") |
|||
def test_it_does_not_show_more_than_10_other_checks(self, mock_post): |
|||
mock_post.return_value.status_code = 200 |
|||
|
|||
for i in range(0, 11): |
|||
other = Check(project=self.project) |
|||
other.name = f"Foobar #{i}" |
|||
other.status = "down" |
|||
other.last_ping = now() - td(minutes=61) |
|||
other.save() |
|||
|
|||
self.channel.notify(self.check) |
|||
|
|||
args, kwargs = mock_post.call_args |
|||
payload = kwargs["json"] |
|||
self.assertNotIn("Foobar", payload["message"]) |
|||
self.assertIn("11 other checks are also down.", payload["message"]) |
@ -0,0 +1,72 @@ |
|||
from __future__ import annotations |
|||
|
|||
from django.test.utils import override_settings |
|||
|
|||
from hc.api.models import Channel, Check |
|||
from hc.test import BaseTestCase |
|||
|
|||
|
|||
class AddNtfyTestCase(BaseTestCase): |
|||
def setUp(self): |
|||
super().setUp() |
|||
self.check = Check.objects.create(project=self.project) |
|||
self.url = f"/projects/{self.project.code}/add_ntfy/" |
|||
|
|||
def test_instructions_work(self): |
|||
self.client.login(username="alice@example.org", password="password") |
|||
r = self.client.get(self.url) |
|||
self.assertContains(r, "simple HTTP-based pub-sub") |
|||
|
|||
def test_it_creates_channel(self): |
|||
form = { |
|||
"topic": "foo", |
|||
"url": "https://example.org", |
|||
"priority": "5", |
|||
"priority_up": "1", |
|||
} |
|||
|
|||
self.client.login(username="alice@example.org", password="password") |
|||
r = self.client.post(self.url, form) |
|||
self.assertRedirects(r, self.channels_url) |
|||
|
|||
c = Channel.objects.get() |
|||
self.assertEqual(c.kind, "ntfy") |
|||
self.assertEqual(c.ntfy_topic, "foo") |
|||
self.assertEqual(c.ntfy_url, "https://example.org") |
|||
self.assertEqual(c.ntfy_priority, 5) |
|||
self.assertEqual(c.ntfy_priority_up, 1) |
|||
self.assertEqual(c.project, self.project) |
|||
|
|||
# Make sure it calls assign_all_checks |
|||
self.assertEqual(c.checks.count(), 1) |
|||
|
|||
def test_it_requires_topic(self): |
|||
form = { |
|||
"url": "https://example.org", |
|||
"priority": "5", |
|||
"priority_up": "1", |
|||
} |
|||
|
|||
self.client.login(username="alice@example.org", password="password") |
|||
r = self.client.post(self.url, form) |
|||
self.assertContains(r, "This field is required") |
|||
|
|||
def test_it_validates_url(self): |
|||
form = { |
|||
"topic": "foo", |
|||
"url": "this is not an url", |
|||
"priority": "5", |
|||
"priority_up": "1", |
|||
} |
|||
|
|||
self.client.login(username="alice@example.org", password="password") |
|||
r = self.client.post(self.url, form) |
|||
self.assertContains(r, "Enter a valid URL") |
|||
|
|||
def test_it_requires_rw_access(self): |
|||
self.bobs_membership.role = "r" |
|||
self.bobs_membership.save() |
|||
|
|||
self.client.login(username="bob@example.org", password="password") |
|||
r = self.client.get(self.url) |
|||
self.assertEqual(r.status_code, 403) |
@ -0,0 +1,61 @@ |
|||
from __future__ import annotations |
|||
|
|||
import json |
|||
|
|||
from hc.api.models import Channel, Check |
|||
from hc.test import BaseTestCase |
|||
|
|||
|
|||
class EditNtfyTestCase(BaseTestCase): |
|||
def setUp(self): |
|||
super().setUp() |
|||
self.check = Check.objects.create(project=self.project) |
|||
|
|||
self.channel = Channel(project=self.project, kind="ntfy") |
|||
self.channel.value = json.dumps( |
|||
{ |
|||
"topic": "foo-bar-baz", |
|||
"url": "https://example.org", |
|||
"priority": 3, |
|||
"priority_up": 0, |
|||
} |
|||
) |
|||
self.channel.save() |
|||
|
|||
self.url = f"/integrations/{self.channel.code}/edit/" |
|||
|
|||
def test_instructions_work(self): |
|||
self.client.login(username="alice@example.org", password="password") |
|||
r = self.client.get(self.url) |
|||
self.assertContains(r, "Save Integration") |
|||
self.assertContains(r, "https://example.org") |
|||
self.assertContains(r, "foo-bar-baz") |
|||
|
|||
def test_it_updates_channel(self): |
|||
form = { |
|||
"topic": "updated-topic", |
|||
"url": "https://example.com", |
|||
"priority": "4", |
|||
"priority_up": "1", |
|||
} |
|||
|
|||
self.client.login(username="alice@example.org", password="password") |
|||
r = self.client.post(self.url, form) |
|||
self.assertRedirects(r, self.channels_url) |
|||
|
|||
self.channel.refresh_from_db() |
|||
self.assertEqual(self.channel.ntfy_topic, "updated-topic") |
|||
self.assertEqual(self.channel.ntfy_url, "https://example.com") |
|||
self.assertEqual(self.channel.ntfy_priority, 4) |
|||
self.assertEqual(self.channel.ntfy_priority_up, 1) |
|||
|
|||
# Make sure it does not call assign_all_checks |
|||
self.assertFalse(self.channel.checks.exists()) |
|||
|
|||
def test_it_requires_rw_access(self): |
|||
self.bobs_membership.role = "r" |
|||
self.bobs_membership.save() |
|||
|
|||
self.client.login(username="bob@example.org", password="password") |
|||
r = self.client.get(self.url) |
|||
self.assertEqual(r.status_code, 403) |
@ -1,3 +0,0 @@ |
|||
#add-pushover .help { |
|||
opacity: 0.6; |
|||
} |
5
static/fonts/icomoon.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
After Width: 128 | Height: 112 | Size: 2.6 KiB |
@ -0,0 +1,159 @@ |
|||
{% extends "base.html" %} |
|||
{% load compress humanize static hc_extras %} |
|||
|
|||
{% block title %}ntfy Integration for {{ site_name }}{% endblock %} |
|||
|
|||
|
|||
{% block content %} |
|||
<div class="row"> |
|||
<div class="col-sm-12"> |
|||
<h1>ntfy</h1> |
|||
<div class="jumbotron"> |
|||
<p> |
|||
<a href="https://ntfy.sh/">ntfy</a> is is a simple HTTP-based pub-sub |
|||
notification service. If you use or plan on using ntfy, |
|||
you can can integrate it with your {{ site_name }} account in few simple steps. |
|||
</p> |
|||
</div> |
|||
|
|||
<h2>Integration Settings</h2> |
|||
|
|||
<form method="post" class="form-horizontal"> |
|||
{% csrf_token %} |
|||
<div class="form-group {{ form.topic.css_classes }}"> |
|||
<label for="topic" class="col-sm-3 control-label">Topic</label> |
|||
<div class="col-sm-6"> |
|||
<input |
|||
id="topic" |
|||
name="topic" |
|||
type="text" |
|||
class="form-control" |
|||
value="{{ form.topic.value|default:'' }}" |
|||
required> |
|||
{% if form.topic.errors %} |
|||
<div class="help-block">{{ form.topic.errors|join:"" }}</div> |
|||
{% endif %} |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group {{ form.url.css_classes }}"> |
|||
<label for="url" class="col-sm-3 control-label">Server URL</label> |
|||
<div class="col-sm-6"> |
|||
<input |
|||
id="url" |
|||
name="url" |
|||
type="text" |
|||
class="form-control" |
|||
placeholder="https://" |
|||
value="{{ form.url.value|default:'https://ntfy.sh' }}" |
|||
required> |
|||
|
|||
{% if form.url.errors %} |
|||
<div class="help-block">{{ form.url.errors|join:"" }}</div> |
|||
{% endif %} |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label class="col-sm-3 control-label">Priority for "down" events</label> |
|||
<div class="col-sm-8"> |
|||
<select name="priority" class="selectpicker form-control"> |
|||
<option |
|||
value="5" |
|||
{% if form.priority.value == 5 %}selected="selected"{% endif %} |
|||
data-content="Max priority. <span class='help'>Really long vibration bursts, default notification sound with a pop-over notification.</span>"> |
|||
Max priority |
|||
</option> |
|||
<option |
|||
value="4" |
|||
{% if form.priority.value == 4 %}selected="selected"{% endif %} |
|||
data-content="High priority. <span class='help'>Long vibration burst, default notification sound with a pop-over notification.</span>"> |
|||
High priority |
|||
</option> |
|||
<option value="3" |
|||
{% if form.priority.value == 3 %}selected="selected"{% endif %} |
|||
data-content="Default priority. <span class='help'>Short default vibration and sound. Default notification behavior.</span>"> |
|||
Default priority |
|||
</option> |
|||
<option |
|||
value="2" |
|||
{% if form.priority.value == 2 %}selected="selected"{% endif %} |
|||
data-content="Low priority. <span class='help'>No vibration or sound. Notification will not visibly show up until notification drawer is pulled down.</span>"> |
|||
Low priority |
|||
</option> |
|||
<option |
|||
value="1" |
|||
{% if form.priority.value == 1 %}selected="selected"{% endif %} |
|||
data-content="Min priority. <span class='help'>No vibration or sound. The notification will be under the fold in 'Other notifications'.</span>"> |
|||
Min priority |
|||
</option> |
|||
<option |
|||
value="0" |
|||
{% if form.priority.value == 0 %}selected="selected"{% endif %} |
|||
data-content="Disabled. <span class='help'>Does not notify about Down events.</span>"> |
|||
Disabled |
|||
</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label class="col-sm-3 control-label">Priority for "up" events</label> |
|||
<div class="col-sm-8"> |
|||
<select name="priority_up" class="selectpicker form-control"> |
|||
<option |
|||
value="5" |
|||
{% if form.priority_up.value == 5 %}selected="selected"{% endif %} |
|||
data-content="Max priority. <span class='help'>Really long vibration bursts, default notification sound with a pop-over notification.</span>"> |
|||
Max priority |
|||
</option> |
|||
<option |
|||
value="4" |
|||
{% if form.priority_up.value == 4 %}selected="selected"{% endif %} |
|||
data-content="High priority. <span class='help'>Long vibration burst, default notification sound with a pop-over notification.</span>"> |
|||
High priority |
|||
</option> |
|||
<option value="3" |
|||
{% if form.priority_up.value == 3 %}selected="selected"{% endif %} |
|||
data-content="Default priority. <span class='help'>Short default vibration and sound. Default notification behavior.</span>"> |
|||
Default priority |
|||
</option> |
|||
<option |
|||
value="2" |
|||
{% if form.priority_up.value == 2 %}selected="selected"{% endif %} |
|||
data-content="Low priority. <span class='help'>No vibration or sound. Notification will not visibly show up until notification drawer is pulled down.</span>"> |
|||
Low priority |
|||
</option> |
|||
<option |
|||
value="1" |
|||
{% if form.priority_up.value == 1 %}selected="selected"{% endif %} |
|||
data-content="Min priority. <span class='help'>No vibration or sound. The notification will be under the fold in 'Other notifications'.</span>"> |
|||
Min priority |
|||
</option> |
|||
<option |
|||
value="0" |
|||
{% if form.priority_up.value == 0 %}selected="selected"{% endif %} |
|||
data-content="Disabled. <span class='help'>Does not notify about Up events.</span>"> |
|||
Disabled |
|||
</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<div class="col-sm-offset-2 col-sm-10"> |
|||
<button type="submit" class="btn btn-primary">Save Integration</button> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
{% endblock %} |
|||
|
|||
{% block scripts %} |
|||
{% compress js %} |
|||
<script src="{% static 'js/jquery-3.6.0.min.js' %}"></script> |
|||
<script src="{% static 'js/bootstrap.min.js' %}"></script> |
|||
<script src="{% static 'js/bootstrap-select.min.js' %}"></script> |
|||
{% endcompress %} |
|||
{% endblock %} |
@ -0,0 +1,21 @@ |
|||
{% load humanize linemode %}{% linemode %} |
|||
{% if check.status == "down" %} |
|||
{% line %}The last ping was {{ check.last_ping|naturaltime }}.{% endline %} |
|||
{% endif %} |
|||
|
|||
{% if down_checks is not None %} |
|||
{% line %}{% endline %} |
|||
{% if down_checks %} |
|||
{% if down_checks|length > 10 %} |
|||
{% line %}{{ down_checks|length }} other checks are {% if check.status == "down" %}also{% else %}still{% endif %} down.{% endline %} |
|||
{% else %} |
|||
{% line %}The following checks are {% if check.status == "down" %}also{% else %}still{% endif %} down:{% endline %} |
|||
{% for c in down_checks %} |
|||
{% line %}- {{ c.name_then_code|safe }} (last ping: {{ c.last_ping|naturaltime }}){% endline %} |
|||
{% endfor %} |
|||
{% endif %} |
|||
{% else %} |
|||
{% line %}All the other checks are up.{% endline %} |
|||
{% endif %} |
|||
{% endif %} |
|||
{% endlinemode %} |
@ -0,0 +1 @@ |
|||
{{ check.name_then_code|safe }} is {{ check.status|upper }} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue