mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-08 06:30:05 +00:00
Add API support for OnCalendar schedules
This commit is contained in:
parent
980520e3b2
commit
fc56cf2635
4 changed files with 75 additions and 21 deletions
|
@ -408,7 +408,7 @@ class Check(models.Model):
|
|||
|
||||
if self.kind == "simple":
|
||||
result["timeout"] = int(self.timeout.total_seconds())
|
||||
elif self.kind == "cron":
|
||||
elif self.kind in ("cron", "oncalendar"):
|
||||
result["schedule"] = self.schedule
|
||||
result["tz"] = self.tz
|
||||
|
||||
|
|
|
@ -314,7 +314,7 @@ class CreateCheckTestCase(BaseTestCase):
|
|||
def test_it_validates_cron_expression(self) -> None:
|
||||
r = self.post(
|
||||
{"schedule": "bad-expression", "tz": "Europe/Riga", "grace": 60},
|
||||
expect_fragment="schedule is not a valid cron expression",
|
||||
expect_fragment="schedule is not a valid cron or OnCalendar expression",
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
|
@ -333,6 +333,24 @@ class CreateCheckTestCase(BaseTestCase):
|
|||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_supports_oncalendar_syntax(self) -> None:
|
||||
r = self.post({"schedule": "12:34", "tz": "Europe/Riga", "grace": 60})
|
||||
self.assertEqual(r.status_code, 201)
|
||||
|
||||
doc = r.json()
|
||||
self.assertEqual(doc["schedule"], "12:34")
|
||||
self.assertEqual(doc["tz"], "Europe/Riga")
|
||||
self.assertEqual(doc["grace"], 60)
|
||||
|
||||
self.assertTrue("timeout" not in doc)
|
||||
|
||||
def test_it_validates_oncalendar_expression(self) -> None:
|
||||
r = self.post(
|
||||
{"schedule": "12:34\nsurprise", "tz": "Europe/Riga", "grace": 60},
|
||||
expect_fragment="schedule is not a valid cron or OnCalendar expression",
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_sets_default_timeout(self) -> None:
|
||||
r = self.post({})
|
||||
self.assertEqual(r.status_code, 201)
|
||||
|
|
|
@ -110,6 +110,18 @@ class UpdateCheckTestCase(BaseTestCase):
|
|||
self.check.refresh_from_db()
|
||||
self.assertEqual(self.check.kind, "simple")
|
||||
|
||||
def test_it_updates_cron_to_oncalendar(self) -> None:
|
||||
self.check.kind = "cron"
|
||||
self.check.schedule = "5 * * * *"
|
||||
self.check.save()
|
||||
|
||||
r = self.post(self.check.code, {"schedule": "12:34"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.check.refresh_from_db()
|
||||
self.assertEqual(self.check.kind, "oncalendar")
|
||||
self.assertEqual(self.check.schedule, "12:34")
|
||||
|
||||
def test_it_sets_single_channel(self) -> None:
|
||||
channel = Channel.objects.create(project=self.project)
|
||||
# Create another channel so we can test that only the first one
|
||||
|
@ -261,13 +273,14 @@ class UpdateCheckTestCase(BaseTestCase):
|
|||
r = self.post(self.check.code, {field: None})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_validates_cron_expression(self) -> None:
|
||||
def test_it_validates_schedule(self) -> None:
|
||||
self.check.kind = "cron"
|
||||
self.check.schedule = "5 * * * *"
|
||||
self.check.save()
|
||||
|
||||
samples = ["* invalid *", "1,2 61 * * *", "0 0 31 2 *"]
|
||||
for sample in samples:
|
||||
cron_samples = ["* invalid *", "1,2 61 * * *", "0 0 31 2 *"]
|
||||
oncalendar_samples = ["Surprise 12:34", "12:34 Europe/surprise"]
|
||||
for sample in cron_samples + oncalendar_samples:
|
||||
r = self.post(self.check.code, {"schedule": sample})
|
||||
self.assertEqual(r.status_code, 400, f"Did not reject '{sample}'")
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import time
|
|||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import timedelta as td
|
||||
from datetime import timezone
|
||||
from email import message_from_bytes
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
@ -29,6 +30,7 @@ from django.utils.timezone import now
|
|||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
from oncalendar import OnCalendar, OnCalendarError
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
|
||||
|
@ -47,6 +49,14 @@ class BadChannelException(Exception):
|
|||
self.message = message
|
||||
|
||||
|
||||
def guess_kind(schedule: str) -> str:
|
||||
# If it is a single line with 5 components, it is probably a cron expression:
|
||||
if "\n" not in schedule.strip() and len(schedule.split()) == 5:
|
||||
return "cron"
|
||||
|
||||
return "oncalendar"
|
||||
|
||||
|
||||
class Spec(BaseModel):
|
||||
channels: str | None = None
|
||||
desc: str | None = None
|
||||
|
@ -96,20 +106,32 @@ class Spec(BaseModel):
|
|||
@field_validator("schedule")
|
||||
@classmethod
|
||||
def check_schedule(cls, v: str) -> str:
|
||||
# Does it have 5 components?
|
||||
if len(v.split()) != 5:
|
||||
raise PydanticCustomError("cron_syntax", "not a valid cron expression")
|
||||
|
||||
try:
|
||||
# Does cronsim accept the schedule?
|
||||
it = CronSim(v, datetime(2000, 1, 1))
|
||||
# Can it calculate the next datetime?
|
||||
next(it)
|
||||
except (CronSimError, StopIteration):
|
||||
raise PydanticCustomError("cron_syntax", "not a valid cron expression")
|
||||
if guess_kind(v) == "cron":
|
||||
try:
|
||||
# Test if cronsim accepts it and can calculate the next datetime
|
||||
it = CronSim(v, datetime(2000, 1, 1))
|
||||
next(it)
|
||||
except (CronSimError, StopIteration):
|
||||
raise PydanticCustomError("cron_syntax", "not a valid cron expression")
|
||||
else:
|
||||
try:
|
||||
# Test if oncalendar accepts it, and can calculate the next datetime
|
||||
it = OnCalendar(v, datetime(2000, 1, 1, tzinfo=timezone.utc))
|
||||
next(it)
|
||||
except (OnCalendarError, StopIteration):
|
||||
raise PydanticCustomError("cron_syntax", "not a valid expression")
|
||||
|
||||
return v
|
||||
|
||||
def kind(self) -> str | None:
|
||||
if self.schedule:
|
||||
return guess_kind(self.schedule)
|
||||
|
||||
if self.timeout:
|
||||
return "simple"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
CUSTOM_ERRORS = {
|
||||
"too_long": "%s is too long",
|
||||
|
@ -122,7 +144,7 @@ CUSTOM_ERRORS = {
|
|||
"bool_type": "%s is not a boolean",
|
||||
"literal_error": "%s has unexpected value",
|
||||
"list_type": "%s is not an array",
|
||||
"cron_syntax": "%s is not a valid cron expression",
|
||||
"cron_syntax": "%s is not a valid cron or OnCalendar expression",
|
||||
"tz_syntax": "%s is not a valid timezone",
|
||||
"time_delta_type": "%s is not a number",
|
||||
}
|
||||
|
@ -290,15 +312,16 @@ def _update(check: Check, spec: Spec, v: int) -> None:
|
|||
check.slug = slugify(spec.name)
|
||||
need_save = True
|
||||
|
||||
if spec.timeout is not None and spec.schedule is None:
|
||||
kind = spec.kind()
|
||||
if kind == "simple":
|
||||
if check.kind != "simple" or check.timeout != spec.timeout:
|
||||
check.kind = "simple"
|
||||
check.timeout = spec.timeout
|
||||
need_save = True
|
||||
|
||||
if spec.schedule is not None:
|
||||
if check.kind != "cron" or check.schedule != spec.schedule:
|
||||
check.kind = "cron"
|
||||
if kind in ("cron", "oncalendar"):
|
||||
if check.kind != kind or check.schedule != spec.schedule:
|
||||
check.kind = kind
|
||||
check.schedule = spec.schedule
|
||||
need_save = True
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue