mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-15 09:24:11 +00:00
parent
4a5123d67b
commit
4d69ff937e
10 changed files with 80 additions and 20 deletions
|
@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
|
||||||
- Upgrade to cronsim 2.3
|
- Upgrade to cronsim 2.3
|
||||||
- Add support for the $BODY placeholder in webhook payloads (#708)
|
- Add support for the $BODY placeholder in webhook payloads (#708)
|
||||||
- Implement the "Clear Events" function
|
- Implement the "Clear Events" function
|
||||||
|
- Add support for custom topics in Zulip notifications (#583)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
- Fix the handling of TooManyRedirects exceptions
|
- Fix the handling of TooManyRedirects exceptions
|
||||||
|
|
|
@ -952,6 +952,11 @@ class Channel(models.Model):
|
||||||
assert self.kind == "zulip"
|
assert self.kind == "zulip"
|
||||||
return self.json["to"]
|
return self.json["to"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def zulip_topic(self):
|
||||||
|
assert self.kind == "zulip"
|
||||||
|
return self.json.get("topic", "")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def linenotify_token(self):
|
def linenotify_token(self):
|
||||||
assert self.kind == "linenotify"
|
assert self.kind == "linenotify"
|
||||||
|
|
|
@ -20,18 +20,21 @@ class NotifyZulipTestCase(BaseTestCase):
|
||||||
self.check.last_ping = now() - td(minutes=61)
|
self.check.last_ping = now() - td(minutes=61)
|
||||||
self.check.save()
|
self.check.save()
|
||||||
|
|
||||||
definition = {
|
self.channel = Channel(project=self.project)
|
||||||
|
self.channel.kind = "zulip"
|
||||||
|
self.channel.value = json.dumps(self.definition())
|
||||||
|
self.channel.save()
|
||||||
|
self.channel.checks.add(self.check)
|
||||||
|
|
||||||
|
def definition(self, **kwargs):
|
||||||
|
d = {
|
||||||
"bot_email": "bot@example.org",
|
"bot_email": "bot@example.org",
|
||||||
"api_key": "fake-key",
|
"api_key": "fake-key",
|
||||||
"mtype": "stream",
|
"mtype": "stream",
|
||||||
"to": "general",
|
"to": "general",
|
||||||
}
|
}
|
||||||
|
d.update(kwargs)
|
||||||
self.channel = Channel(project=self.project)
|
return d
|
||||||
self.channel.kind = "zulip"
|
|
||||||
self.channel.value = json.dumps(definition)
|
|
||||||
self.channel.save()
|
|
||||||
self.channel.checks.add(self.check)
|
|
||||||
|
|
||||||
@patch("hc.api.transports.curl.request")
|
@patch("hc.api.transports.curl.request")
|
||||||
def test_it_works(self, mock_post):
|
def test_it_works(self, mock_post):
|
||||||
|
@ -51,6 +54,18 @@ class NotifyZulipTestCase(BaseTestCase):
|
||||||
serialized = json.dumps(payload)
|
serialized = json.dumps(payload)
|
||||||
self.assertNotIn(str(self.check.code), serialized)
|
self.assertNotIn(str(self.check.code), serialized)
|
||||||
|
|
||||||
|
@patch("hc.api.transports.curl.request")
|
||||||
|
def test_it_uses_custom_topic(self, mock_post):
|
||||||
|
self.channel.value = json.dumps(self.definition(topic="foo"))
|
||||||
|
self.channel.save()
|
||||||
|
|
||||||
|
mock_post.return_value.status_code = 200
|
||||||
|
self.channel.notify(self.check)
|
||||||
|
|
||||||
|
args, kwargs = mock_post.call_args
|
||||||
|
payload = kwargs["data"]
|
||||||
|
self.assertEqual(payload["topic"], "foo")
|
||||||
|
|
||||||
@patch("hc.api.transports.curl.request")
|
@patch("hc.api.transports.curl.request")
|
||||||
def test_it_returns_error(self, mock_post):
|
def test_it_returns_error(self, mock_post):
|
||||||
mock_post.return_value.status_code = 403
|
mock_post.return_value.status_code = 403
|
||||||
|
|
|
@ -262,7 +262,9 @@ class HttpTransport(Transport):
|
||||||
|
|
||||||
|
|
||||||
class Webhook(HttpTransport):
|
class Webhook(HttpTransport):
|
||||||
def prepare(self, template: str, check, urlencode=False, latin1=False, allow_ping_body=False) -> str:
|
def prepare(
|
||||||
|
self, template: str, check, urlencode=False, latin1=False, allow_ping_body=False
|
||||||
|
) -> str:
|
||||||
"""Replace variables with actual values."""
|
"""Replace variables with actual values."""
|
||||||
|
|
||||||
def safe(s: str) -> str:
|
def safe(s: str) -> str:
|
||||||
|
@ -821,12 +823,16 @@ class Zulip(HttpTransport):
|
||||||
if not settings.ZULIP_ENABLED:
|
if not settings.ZULIP_ENABLED:
|
||||||
raise TransportError("Zulip notifications are not enabled.")
|
raise TransportError("Zulip notifications are not enabled.")
|
||||||
|
|
||||||
|
topic = self.channel.zulip_topic
|
||||||
|
if not topic:
|
||||||
|
topic = tmpl("zulip_topic.html", check=check)
|
||||||
|
|
||||||
url = self.channel.zulip_site + "/api/v1/messages"
|
url = self.channel.zulip_site + "/api/v1/messages"
|
||||||
auth = (self.channel.zulip_bot_email, self.channel.zulip_api_key)
|
auth = (self.channel.zulip_bot_email, self.channel.zulip_api_key)
|
||||||
data = {
|
data = {
|
||||||
"type": self.channel.zulip_type,
|
"type": self.channel.zulip_type,
|
||||||
"to": self.channel.zulip_to,
|
"to": self.channel.zulip_to,
|
||||||
"topic": tmpl("zulip_topic.html", check=check),
|
"topic": topic,
|
||||||
"content": tmpl("zulip_content.html", check=check),
|
"content": tmpl("zulip_content.html", check=check),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -311,6 +311,7 @@ class AddZulipForm(forms.Form):
|
||||||
site = forms.URLField(max_length=100, validators=[WebhookValidator()])
|
site = forms.URLField(max_length=100, validators=[WebhookValidator()])
|
||||||
mtype = forms.ChoiceField(choices=ZULIP_TARGETS)
|
mtype = forms.ChoiceField(choices=ZULIP_TARGETS)
|
||||||
to = forms.CharField(max_length=100)
|
to = forms.CharField(max_length=100)
|
||||||
|
topic = forms.CharField(max_length=100, required=False)
|
||||||
|
|
||||||
def get_value(self):
|
def get_value(self):
|
||||||
return json.dumps(dict(self.cleaned_data), sort_keys=True)
|
return json.dumps(dict(self.cleaned_data), sort_keys=True)
|
||||||
|
@ -349,3 +350,7 @@ class SeekForm(forms.Form):
|
||||||
|
|
||||||
def clean_end(self):
|
def clean_end(self):
|
||||||
return datetime.fromtimestamp(self.cleaned_data["end"], tz=timezone.utc)
|
return datetime.fromtimestamp(self.cleaned_data["end"], tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class TransferForm(forms.Form):
|
||||||
|
project = forms.UUIDField()
|
||||||
|
|
|
@ -10,6 +10,7 @@ def _get_payload(**kwargs):
|
||||||
"site": "https://example.org",
|
"site": "https://example.org",
|
||||||
"mtype": "stream",
|
"mtype": "stream",
|
||||||
"to": "general",
|
"to": "general",
|
||||||
|
"topic": "foo",
|
||||||
}
|
}
|
||||||
|
|
||||||
payload.update(kwargs)
|
payload.update(kwargs)
|
||||||
|
@ -37,6 +38,7 @@ class AddZulipTestCase(BaseTestCase):
|
||||||
self.assertEqual(c.zulip_api_key, "fake-key")
|
self.assertEqual(c.zulip_api_key, "fake-key")
|
||||||
self.assertEqual(c.zulip_type, "stream")
|
self.assertEqual(c.zulip_type, "stream")
|
||||||
self.assertEqual(c.zulip_to, "general")
|
self.assertEqual(c.zulip_to, "general")
|
||||||
|
self.assertEqual(c.zulip_topic, "foo")
|
||||||
|
|
||||||
def test_it_rejects_bad_email(self):
|
def test_it_rejects_bad_email(self):
|
||||||
payload = _get_payload(bot_email="not@an@email")
|
payload = _get_payload(bot_email="not@an@email")
|
||||||
|
|
|
@ -73,3 +73,9 @@ class TransferTestCase(BaseTestCase):
|
||||||
self.client.login(username="bob@example.org", password="password")
|
self.client.login(username="bob@example.org", password="password")
|
||||||
r = self.client.post(self.url, payload)
|
r = self.client.post(self.url, payload)
|
||||||
self.assertEqual(r.status_code, 403)
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
def test_it_handles_bad_project_uuid(self):
|
||||||
|
self.client.login(username="bob@example.org", password="password")
|
||||||
|
payload = {"project": "not-uuid"}
|
||||||
|
r = self.client.post(self.url, payload)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
|
@ -139,10 +139,10 @@ def _get_rw_channel_for_user(request, code):
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
|
|
||||||
def _get_project_for_user(request, project_code):
|
def _get_project_for_user(request: HttpRequest, code: UUID) -> Tuple[Project, bool]:
|
||||||
"""Check access, return (project, rw) tuple."""
|
"""Check access, return (project, rw) tuple."""
|
||||||
|
|
||||||
project = get_object_or_404(Project, code=project_code)
|
project = get_object_or_404(Project, code=code)
|
||||||
if request.user.is_superuser:
|
if request.user.is_superuser:
|
||||||
return project, True
|
return project, True
|
||||||
|
|
||||||
|
@ -154,10 +154,10 @@ def _get_project_for_user(request, project_code):
|
||||||
return project, membership.is_rw
|
return project, membership.is_rw
|
||||||
|
|
||||||
|
|
||||||
def _get_rw_project_for_user(request, project_code):
|
def _get_rw_project_for_user(request: HttpRequest, code: UUID) -> Project:
|
||||||
"""Check access, return (project, rw) tuple."""
|
"""Check access, return (project, rw) tuple."""
|
||||||
|
|
||||||
project, rw = _get_project_for_user(request, project_code)
|
project, rw = _get_project_for_user(request, code)
|
||||||
if not rw:
|
if not rw:
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
|
@ -818,11 +818,15 @@ def uncloak(request, unique_key):
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def transfer(request, code):
|
def transfer(request: HttpRequest, code: UUID) -> HttpResponse:
|
||||||
check = _get_rw_check_for_user(request, code)
|
check = _get_rw_check_for_user(request, code)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
target_project = _get_rw_project_for_user(request, request.POST["project"])
|
form = forms.TransferForm(request.POST)
|
||||||
|
if not form.is_valid():
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
target_project = _get_rw_project_for_user(request, form.cleaned_data["project"])
|
||||||
if target_project.num_checks_available() <= 0:
|
if target_project.num_checks_available() <= 0:
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
@ -1696,7 +1700,7 @@ def add_victorops(request, code):
|
||||||
|
|
||||||
@require_setting("ZULIP_ENABLED")
|
@require_setting("ZULIP_ENABLED")
|
||||||
@login_required
|
@login_required
|
||||||
def add_zulip(request, code):
|
def add_zulip(request: HttpRequest, code: UUID) -> HttpResponse:
|
||||||
project = _get_rw_project_for_user(request, code)
|
project = _get_rw_project_for_user(request, code)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
|
|
@ -2,13 +2,15 @@ $(function() {
|
||||||
function updateForm() {
|
function updateForm() {
|
||||||
var mType = $('input[name=mtype]:checked').val();
|
var mType = $('input[name=mtype]:checked').val();
|
||||||
if (mType == "stream") {
|
if (mType == "stream") {
|
||||||
$("#z-to-label").text("Stream Name");
|
$("#z-to-label").text("Stream");
|
||||||
$("#z-to-help").text('Example: "general"');
|
$("#z-to-help").text('Example: "general"');
|
||||||
}
|
}
|
||||||
if (mType == "private") {
|
if (mType == "private") {
|
||||||
$("#z-to-label").text("User's Email");
|
$("#z-to-label").text("User's Email");
|
||||||
$("#z-to-help").text('Example: "alice@example.org"');
|
$("#z-to-help").text('Example: "alice@example.org"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$("#z-topic-group").toggleClass("hide", mType == "private");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update form labels when user clicks on radio buttons
|
// Update form labels when user clicks on radio buttons
|
||||||
|
|
|
@ -104,8 +104,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div id="z-mtype-group" class="form-group {{ form.mtype.css_classes }}">
|
<div id="z-mtype-group" class="form-group {{ form.mtype.css_classes }}">
|
||||||
<label class="col-sm-2 control-label">Post To</label>
|
<label class="col-sm-2 control-label">Post To</label>
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
|
@ -135,7 +133,7 @@
|
||||||
{% if form.mtype.value == "private" %}
|
{% if form.mtype.value == "private" %}
|
||||||
User's Email
|
User's Email
|
||||||
{% else %}
|
{% else %}
|
||||||
Stream Name
|
Stream
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
|
@ -156,6 +154,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="z-topic-group" class="form-group {% if form.mtype.value == 'private' %}hide{% endif %} {{ form.topic.css_classes }}">
|
||||||
|
<label for="z-topic" class="col-sm-2 control-label">Topic</label>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<input
|
||||||
|
id="z-topic"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
name="topic"
|
||||||
|
value="{{ form.topic.value|default:"" }}">
|
||||||
|
<div class="help-block">
|
||||||
|
Example: "Alerts". Leave empty to use auto-generated topics in the form:
|
||||||
|
"{check name} is {status}".
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-2 col-sm-10">
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
<button
|
<button
|
||||||
|
|
Loading…
Add table
Reference in a new issue