mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-08 14:40:05 +00:00
Add /api/v3/ (adds ability to set slug when creating or updating checks)
This commit is contained in:
parent
d7d9702ee0
commit
4ccee09f73
16 changed files with 105 additions and 21 deletions
|
@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Configure logging to log unhandled exceptions to console even when DEBUG=False (#835)
|
||||
- Make hc.lib.emails raise exceptions when EMAIL_ settings are not set
|
||||
- Decouple check's name from slug, allow users to set hand-picked slugs
|
||||
- Add /api/v3/ (adds ability to specify slug when creating or updating checks)
|
||||
|
||||
## v2.9.2 - 2023-06-05
|
||||
|
||||
|
|
|
@ -79,7 +79,8 @@ def _make_user(email, tz=None, with_project=True):
|
|||
project.save()
|
||||
|
||||
check = Check(project=project)
|
||||
check.set_name_slug("My First Check")
|
||||
check.name = "My First Check"
|
||||
check.slug = "my-first-check"
|
||||
check.save()
|
||||
|
||||
channel = Channel(project=project)
|
||||
|
|
|
@ -33,7 +33,11 @@ def authorize(f):
|
|||
return error("wrong api key", 401)
|
||||
|
||||
request.readonly = False
|
||||
request.v = 2 if request.path_info.startswith("/api/v2/") else 1
|
||||
request.v = 1
|
||||
if request.path_info.startswith("/api/v2/"):
|
||||
request.v = 2
|
||||
elif request.path_info.startswith("/api/v3/"):
|
||||
request.v = 3
|
||||
return f(request, *args, **kwds)
|
||||
|
||||
return wrapper
|
||||
|
|
|
@ -19,7 +19,6 @@ from django.core.signing import TimestampSigner
|
|||
from django.db import models, transaction
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
|
||||
from hc.accounts.models import Project
|
||||
|
@ -233,10 +232,6 @@ class Check(models.Model):
|
|||
return self.last_duration
|
||||
return None
|
||||
|
||||
def set_name_slug(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.slug = slugify(name)
|
||||
|
||||
def get_grace_start(self, *, with_started: bool = True) -> datetime | None:
|
||||
"""Return the datetime when the grace period starts.
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ check = {
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "maxLength": 100},
|
||||
"slug": {"type": "string", "pattern": "^[a-zA-Z0-9-_]*$"},
|
||||
"desc": {"type": "string"},
|
||||
"tags": {"type": "string", "maxLength": 500},
|
||||
"timeout": {"type": "number", "minimum": 60, "maximum": 31536000},
|
||||
|
@ -22,7 +23,7 @@ check = {
|
|||
"filter_body": {"type": "boolean"},
|
||||
"unique": {
|
||||
"type": "array",
|
||||
"items": {"enum": ["name", "tags", "timeout", "grace"]},
|
||||
"items": {"enum": ["name", "slug", "tags", "timeout", "grace"]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -172,6 +172,21 @@ class CreateCheckTestCase(BaseTestCase):
|
|||
check.refresh_from_db()
|
||||
self.assertEqual(check.tags, "bar")
|
||||
|
||||
def test_it_supports_unique_slug(self):
|
||||
check = Check.objects.create(project=self.project, slug="foo")
|
||||
|
||||
r = self.post({"slug": "foo", "tags": "bar", "unique": ["slug"]})
|
||||
|
||||
# Expect 200 instead of 201
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# And there should be only one check in the database:
|
||||
self.assertEqual(Check.objects.count(), 1)
|
||||
|
||||
# The tags field should have a value now:
|
||||
check.refresh_from_db()
|
||||
self.assertEqual(check.tags, "bar")
|
||||
|
||||
def test_it_supports_unique_tags(self):
|
||||
Check.objects.create(project=self.project, tags="foo")
|
||||
|
||||
|
@ -205,6 +220,18 @@ class CreateCheckTestCase(BaseTestCase):
|
|||
# And there should be only one check in the database:
|
||||
self.assertEqual(Check.objects.count(), 1)
|
||||
|
||||
def test_it_handles_empty_unique_parameter(self):
|
||||
check = Check.objects.create(project=self.project)
|
||||
|
||||
r = self.post({"name": "Hello", "unique": []})
|
||||
|
||||
# Expect 201
|
||||
self.assertEqual(r.status_code, 201)
|
||||
|
||||
# The pre-existing check should be unchanged:
|
||||
check.refresh_from_db()
|
||||
self.assertEqual(check.name, "")
|
||||
|
||||
def test_it_handles_missing_request_body(self):
|
||||
r = self.client.post(self.URL, content_type="application/json")
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
@ -343,3 +370,25 @@ class CreateCheckTestCase(BaseTestCase):
|
|||
doc = r.json()
|
||||
self.assertEqual(doc["status"], "new")
|
||||
self.assertTrue(doc["started"])
|
||||
|
||||
def test_v3_saves_slug(self):
|
||||
r = self.post({"name": "Foo", "slug": "custom-slug"}, v=3)
|
||||
self.assertEqual(r.status_code, 201)
|
||||
|
||||
check = Check.objects.get()
|
||||
self.assertEqual(check.name, "Foo")
|
||||
self.assertEqual(check.slug, "custom-slug")
|
||||
|
||||
def test_v3_does_not_autogenerate_slug(self):
|
||||
r = self.post({"name": "Foo"}, v=3)
|
||||
self.assertEqual(r.status_code, 201)
|
||||
|
||||
check = Check.objects.get()
|
||||
self.assertEqual(check.slug, "")
|
||||
|
||||
def test_it_handles_invalid_slug(self):
|
||||
r = self.post({"name": "Foo", "slug": "Hey!"}, v=3)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertEqual(
|
||||
r.json()["error"], "json validation error: slug does not match pattern"
|
||||
)
|
||||
|
|
|
@ -15,7 +15,8 @@ class GetCheckTestCase(BaseTestCase):
|
|||
self.now = now().replace(microsecond=0)
|
||||
|
||||
self.a1 = Check(project=self.project)
|
||||
self.a1.set_name_slug("Alice 1")
|
||||
self.a1.name = "Alice 1"
|
||||
self.a1.slug = "alice-1-custom-slug"
|
||||
self.a1.timeout = td(seconds=3600)
|
||||
self.a1.grace = td(seconds=900)
|
||||
self.a1.n_pings = 0
|
||||
|
@ -43,7 +44,7 @@ class GetCheckTestCase(BaseTestCase):
|
|||
doc = r.json()
|
||||
self.assertEqual(len(doc), 25)
|
||||
|
||||
self.assertEqual(doc["slug"], "alice-1")
|
||||
self.assertEqual(doc["slug"], "alice-1-custom-slug")
|
||||
self.assertEqual(doc["timeout"], 3600)
|
||||
self.assertEqual(doc["grace"], 900)
|
||||
self.assertEqual(doc["ping_url"], self.a1.url())
|
||||
|
|
|
@ -61,7 +61,7 @@ class NotifyTestCase(BaseTestCase):
|
|||
self.assertEqual(payload["To"], "+1234567890")
|
||||
|
||||
n = Notification.objects.get()
|
||||
callback_path = f"/api/v2/notifications/{n.code}/status"
|
||||
callback_path = f"/api/v3/notifications/{n.code}/status"
|
||||
self.assertTrue(payload["StatusCallback"].endswith(callback_path))
|
||||
|
||||
@patch("hc.api.transports.curl.request")
|
||||
|
|
|
@ -44,7 +44,7 @@ class NotifySmsTestCase(BaseTestCase):
|
|||
self.assertIn("is DOWN", payload["Body"])
|
||||
|
||||
n = Notification.objects.get()
|
||||
callback_path = f"/api/v2/notifications/{n.code}/status"
|
||||
callback_path = f"/api/v3/notifications/{n.code}/status"
|
||||
self.assertTrue(payload["StatusCallback"].endswith(callback_path))
|
||||
|
||||
# sent SMS counter should go up
|
||||
|
|
|
@ -41,7 +41,7 @@ class NotifyWhatsAppTestCase(BaseTestCase):
|
|||
self.assertEqual(payload["To"], "whatsapp:+1234567890")
|
||||
|
||||
n = Notification.objects.get()
|
||||
callback_path = f"/api/v2/notifications/{n.code}/status"
|
||||
callback_path = f"/api/v3/notifications/{n.code}/status"
|
||||
self.assertTrue(payload["StatusCallback"].endswith(callback_path))
|
||||
|
||||
# sent SMS counter should go up
|
||||
|
|
|
@ -14,7 +14,7 @@ class PruneNotificationsTestCase(BaseTestCase):
|
|||
super().setUp()
|
||||
|
||||
self.check = Check(project=self.project)
|
||||
self.check.set_name_slug("Alice 1")
|
||||
self.check.name = "Alice 1"
|
||||
self.check.n_pings = 101
|
||||
self.check.save()
|
||||
|
||||
|
|
|
@ -398,3 +398,22 @@ class UpdateCheckTestCase(BaseTestCase):
|
|||
doc = r.json()
|
||||
self.assertEqual(doc["status"], "new")
|
||||
self.assertTrue(doc["started"])
|
||||
|
||||
def test_v3_saves_slug(self):
|
||||
payload = {"slug": "updated-slug", "api_key": "X" * 32}
|
||||
r = self.post(self.check.code, payload, v=3)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.check.refresh_from_db()
|
||||
self.assertEqual(self.check.slug, "updated-slug")
|
||||
|
||||
def test_v3_does_not_autogenerate_slug(self):
|
||||
self.check.slug = "foo"
|
||||
self.check.save()
|
||||
|
||||
payload = {"name": "Bar", "api_key": "X" * 32}
|
||||
r = self.post(self.check.code, payload, v=3)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.check.refresh_from_db()
|
||||
self.assertEqual(self.check.slug, "foo")
|
||||
|
|
|
@ -78,6 +78,7 @@ urlpatterns = [
|
|||
path("ping/<slug:ping_key>/<slug:slug>/", include(slug_urls)),
|
||||
path("api/v1/", include(api_urls)),
|
||||
path("api/v2/", include(api_urls)),
|
||||
path("api/v3/", include(api_urls)),
|
||||
path(
|
||||
"badge/<slug:badge_key>/<slug:signature>/<quoted:tag>.<slug:fmt>",
|
||||
views.badge,
|
||||
|
|
|
@ -20,6 +20,7 @@ from django.http import (
|
|||
JsonResponse,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
@ -115,6 +116,8 @@ def _lookup(project, spec):
|
|||
existing_checks = Check.objects.filter(project=project)
|
||||
if "name" in unique_fields:
|
||||
existing_checks = existing_checks.filter(name=spec.get("name"))
|
||||
if "slug" in unique_fields:
|
||||
existing_checks = existing_checks.filter(slug=spec.get("slug"))
|
||||
if "tags" in unique_fields:
|
||||
existing_checks = existing_checks.filter(tags=spec.get("tags"))
|
||||
if "timeout" in unique_fields:
|
||||
|
@ -127,7 +130,7 @@ def _lookup(project, spec):
|
|||
return existing_checks.first()
|
||||
|
||||
|
||||
def _update(check, spec):
|
||||
def _update(check, spec, v: int):
|
||||
# First, validate the supplied channel codes/names
|
||||
if "channels" not in spec:
|
||||
# If the channels key is not present, don't update check's channels
|
||||
|
@ -162,7 +165,10 @@ def _update(check, spec):
|
|||
need_save = True
|
||||
|
||||
if "name" in spec and check.name != spec["name"]:
|
||||
check.set_name_slug(spec["name"])
|
||||
check.name = spec["name"]
|
||||
if v < 3:
|
||||
# v1 and v2 generates slug automatically from name
|
||||
check.slug = slugify(spec["name"])
|
||||
need_save = True
|
||||
|
||||
if "timeout" in spec and "schedule" not in spec:
|
||||
|
@ -195,6 +201,7 @@ def _update(check, spec):
|
|||
need_save = True
|
||||
|
||||
for key in (
|
||||
"slug",
|
||||
"tags",
|
||||
"desc",
|
||||
"manual_resume",
|
||||
|
@ -255,7 +262,7 @@ def create_check(request):
|
|||
created = True
|
||||
|
||||
try:
|
||||
_update(check, request.json)
|
||||
_update(check, request.json, request.v)
|
||||
except BadChannelException as e:
|
||||
return JsonResponse({"error": e.message}, status=400)
|
||||
|
||||
|
@ -308,7 +315,7 @@ def update_check(request, code):
|
|||
return HttpResponseForbidden()
|
||||
|
||||
try:
|
||||
_update(check, request.json)
|
||||
_update(check, request.json, request.v)
|
||||
except BadChannelException as e:
|
||||
return JsonResponse({"error": e.message}, status=400)
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ class CopyCheckTestCase(BaseTestCase):
|
|||
super().setUp()
|
||||
self.check = Check(project=self.project)
|
||||
self.check.name = "Foo"
|
||||
self.check.slug = "foo"
|
||||
self.check.slug = "custom-slug"
|
||||
self.check.filter_subject = True
|
||||
self.check.filter_body = True
|
||||
self.check.start_kw = "start-keyword"
|
||||
|
@ -27,7 +27,7 @@ class CopyCheckTestCase(BaseTestCase):
|
|||
self.assertContains(r, "This is a brand new check")
|
||||
|
||||
copy = Check.objects.get(name="Foo (copy)")
|
||||
self.assertEqual(copy.slug, "foo-copy")
|
||||
self.assertEqual(copy.slug, "custom-slug-copy")
|
||||
self.assertTrue(copy.filter_subject)
|
||||
self.assertTrue(copy.filter_body)
|
||||
self.assertEqual(copy.start_kw, "start-keyword")
|
||||
|
|
|
@ -936,8 +936,13 @@ def copy(request, code):
|
|||
if len(new_name) > 100:
|
||||
new_name = check.name[:90] + "... (copy)"
|
||||
|
||||
new_slug = check.slug + "-copy"
|
||||
if len(new_slug) > 100:
|
||||
new_slug = ""
|
||||
|
||||
copied = Check(project=check.project)
|
||||
copied.set_name_slug(new_name)
|
||||
copied.name = new_name
|
||||
copied.slug = new_slug
|
||||
copied.desc, copied.tags = check.desc, check.tags
|
||||
|
||||
copied.filter_subject = check.filter_subject
|
||||
|
|
Loading…
Add table
Reference in a new issue