diff --git a/hc/front/forms.py b/hc/front/forms.py index 046e9575..6d1d6769 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -85,9 +85,11 @@ class NameTagsForm(forms.Form): class AddCheckForm(NameTagsForm): - kind = forms.ChoiceField(choices=(("simple", "simple"), ("cron", "cron"))) + kind = forms.ChoiceField( + choices=(("simple", "simple"), ("cron", "cron"), ("oncalendar", "oncalendar")) + ) timeout = forms.IntegerField(min_value=60, max_value=31536000) - schedule = forms.CharField(max_length=100, validators=[CronValidator()]) + schedule = forms.CharField(required=False, max_length=100) tz = forms.CharField(max_length=36, validators=[TimezoneValidator()]) grace = forms.IntegerField(min_value=60, max_value=31536000) @@ -97,6 +99,21 @@ class AddCheckForm(NameTagsForm): def clean_grace(self) -> td: return td(seconds=self.cleaned_data["grace"]) + def clean_schedule(self): + kind = self.cleaned_data.get("kind") + validator = None + if kind == "cron": + validator = CronValidator() + elif kind == "oncalendar": + validator = OnCalendarValidator() + else: + # If kind is not cron or oncalendar, ignore the passed in value + # and use "* * * * *" instead. + return "* * * * *" + + validator(self.cleaned_data["schedule"]) + return self.cleaned_data["schedule"] + class FilteringRulesForm(forms.Form): filter_subject = forms.BooleanField(required=False) diff --git a/hc/front/tests/test_add_check.py b/hc/front/tests/test_add_check.py index 0dc00343..6a44f7e7 100644 --- a/hc/front/tests/test_add_check.py +++ b/hc/front/tests/test_add_check.py @@ -19,7 +19,6 @@ class AddCheckTestCase(BaseTestCase): "kind": "simple", "timeout": "120", "grace": "60", - "schedule": "* * * * *", "tz": "Europe/Riga", } payload.update(kwargs) @@ -50,11 +49,24 @@ class AddCheckTestCase(BaseTestCase): def test_it_saves_cron_schedule(self) -> None: self.client.login(username="alice@example.org", password="password") - r = self.client.post(self.url, self._payload(kind="cron")) + r = self.client.post(self.url, self._payload(kind="cron", schedule="0 0 * * *")) check = Check.objects.get() self.assertEqual(check.project, self.project) self.assertEqual(check.kind, "cron") + self.assertEqual(check.schedule, "0 0 * * *") + + self.assertRedirects(r, self.redirect_url) + + def test_it_saves_oncalendar_schedule(self) -> None: + self.client.login(username="alice@example.org", password="password") + payload = self._payload(kind="oncalendar", schedule="12:34") + r = self.client.post(self.url, payload) + + check = Check.objects.get() + self.assertEqual(check.project, self.project) + self.assertEqual(check.kind, "oncalendar") + self.assertEqual(check.schedule, "12:34") self.assertRedirects(r, self.redirect_url) @@ -81,12 +93,21 @@ class AddCheckTestCase(BaseTestCase): def test_it_validates_cron_expression(self) -> None: self.client.login(username="alice@example.org", password="password") - r = self.client.post(self.url, self._payload(schedule="* * *")) + r = self.client.post(self.url, self._payload(kind="cron", schedule="* * *")) self.assertEqual(r.status_code, 400) def test_it_validates_cron_expression_with_no_matches(self) -> None: self.client.login(username="alice@example.org", password="password") - r = self.client.post(self.url, self._payload(schedule="* * */100 * MON#2")) + r = self.client.post( + self.url, self._payload(kind="cron", schedule="* * */100 * MON#2") + ) + self.assertEqual(r.status_code, 400) + + def test_it_validates_oncalendar_expression(self) -> None: + self.client.login(username="alice@example.org", password="password") + r = self.client.post( + self.url, self._payload(kind="oncalendar", schedule="12:345") + ) self.assertEqual(r.status_code, 400) def test_it_validates_tz(self) -> None: diff --git a/hc/front/tests/test_validate_schedule.py b/hc/front/tests/test_validate_schedule.py index fc332e05..3145f9f4 100644 --- a/hc/front/tests/test_validate_schedule.py +++ b/hc/front/tests/test_validate_schedule.py @@ -10,16 +10,28 @@ class ValidateScheduleTestCase(BaseTestCase): super().setUp() self.url = "/checks/validate_schedule/" - def _url(self, schedule: str) -> str: - return "/checks/validate_schedule/?" + urlencode({"schedule": schedule}) + def _url(self, schedule: str, kind: str) -> str: + params = {"schedule": schedule, "kind": kind} + return "/checks/validate_schedule/?" + urlencode(params) - def test_it_works(self) -> None: - r = self.client.get(self._url("* * * * *")) + def test_it_validates_cron_schedule(self) -> None: + r = self.client.get(self._url("* * * * *", "cron")) + self.assertEqual(r.status_code, 200) + self.assertTrue(r.json()["result"]) + + def test_it_validates_oncalendar_schedule(self) -> None: + r = self.client.get(self._url("12:34", "oncalendar")) self.assertEqual(r.status_code, 200) self.assertTrue(r.json()["result"]) def test_it_rejects_bad_schedules(self) -> None: + # Bad cron schedules for v in ["a", "* * * *", "1-1000 * * * *", "0 0 */100 * MON#2"]: - r = self.client.get(self._url(v)) + r = self.client.get(self._url(v, "cron")) + self.assertEqual(r.status_code, 200) + self.assertFalse(r.json()["result"]) + # Bad oncalendar schedules + for v in ["12:345"]: + r = self.client.get(self._url(v, "oncalendar")) self.assertEqual(r.status_code, 200) self.assertFalse(r.json()["result"]) diff --git a/hc/front/views.py b/hc/front/views.py index 6c849085..34cf0173 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -665,14 +665,23 @@ def oncalendar_preview(request: HttpRequest) -> HttpResponse: def validate_schedule(request: HttpRequest) -> HttpResponse: + kind = request.GET.get("kind", "") + iterator: type[CronSim] | type[OnCalendar] + if kind == "cron": + iterator = CronSim + elif kind == "oncalendar": + iterator = OnCalendar + else: + return HttpResponseBadRequest() + schedule = request.GET.get("schedule", "") result = True try: - # Does cronsim accept the schedule? - it = CronSim(schedule, now()) + # Does cronsim/oncalendar accept the schedule? + it = iterator(schedule, now()) # Can it calculate the next datetime? next(it) - except (CronSimError, StopIteration): + except (CronSimError, OnCalendarError, StopIteration): result = False return JsonResponse({"result": result}) diff --git a/static/css/checks.css b/static/css/checks.css index a9c3f81c..46fcd46b 100644 --- a/static/css/checks.css +++ b/static/css/checks.css @@ -81,16 +81,27 @@ margin-bottom: 20px; } +/* Add Check Modal */ + #add-check-modal .modal-body { padding: 15px 40px; } -.simple .create-cron-extra { +#add-check-kind label { + display: inline-block; + margin-right: 20px; +} + +.create-simple-extra, +.create-cron-extra, +.create-oncalendar-extra { display: none; } -.cron .create-simple-extra { - display: none; +.simple .create-simple-extra, +.cron .create-cron-extra, +.oncalendar .create-oncalendar-extra { + display: block; } #add-check-period, diff --git a/static/js/add-check-modal.js b/static/js/add-check-modal.js index 95d2b78b..b2c9d487 100644 --- a/static/js/add-check-modal.js +++ b/static/js/add-check-modal.js @@ -3,7 +3,8 @@ $(function () { var modal = $("#add-check-modal"); var period = document.getElementById("add-check-period"); var periodUnit = document.getElementById("add-check-period-unit"); - var scheduleField = document.getElementById("add-check-schedule"); + var cronField = document.getElementById("add-check-schedule"); + var onCalendarField = document.getElementById("add-check-schedule-oncalendar"); var grace = document.getElementById("add-check-grace"); var graceUnit = document.getElementById("add-check-grace-unit"); @@ -25,15 +26,14 @@ $(function () { function updateScheduleExtras() { var kind = $('#add-check-modal input[name=kind]:checked').val(); - modal.removeClass("cron").removeClass("simple").addClass(kind); - - if (kind == "simple" && !scheduleField.checkValidity()) { - scheduleField.setCustomValidity(""); - scheduleField.value = "* * * * *"; - } + modal.removeClass("simple").removeClass("cron").removeClass("oncalendar").addClass(kind); + // Include cron schedule in POST data only if kind = "cron" + cronField.disabled = kind != "cron"; + // Include OnCalendar schedule in POST data only if kind = "oncalendar" + onCalendarField.disabled = kind != "oncalendar"; } - // Show and hide fields when user clicks simple/cron radio buttons + // Show and hide fields when user clicks simple/cron/oncalendar radio buttons $("#add-check-modal input[type=radio][name=kind]").change(updateScheduleExtras); modal.on("shown.bs.modal", function() { @@ -69,22 +69,26 @@ $(function () { var currentSchedule = ""; function validateSchedule() { - var schedule = scheduleField.value; + var kind = $('#add-check-modal input[name=kind]:checked').val(); + if (kind == "simple") return; + + var field = kind == "cron" ? cronField : onCalendarField; // Return early if the schedule has not changed - if (schedule == currentSchedule) + if (field.value == currentSchedule) return; - currentSchedule = schedule; + currentSchedule = field.value; var token = $('input[name=csrfmiddlewaretoken]').val(); - $.getJSON(base + "/checks/validate_schedule/", {schedule: schedule}, function(data) { - if (schedule != currentSchedule) + var payload = {kind: kind, schedule: field.value}; + $.getJSON(base + "/checks/validate_schedule/", payload, function(data) { + if (field.value != currentSchedule) return; // ignore stale results - scheduleField.setCustomValidity(data.result ? "" : "Please enter a valid cron expression"); + field.setCustomValidity(data.result ? "" : "Please enter a valid expression"); }); } $("#add-check-schedule").on("keyup change", validateSchedule); - + $("#add-check-schedule-oncalendar").on("keyup change", validateSchedule); }); diff --git a/templates/front/add_check_modal.html b/templates/front/add_check_modal.html index 294c1a60..61f0444c 100644 --- a/templates/front/add_check_modal.html +++ b/templates/front/add_check_modal.html @@ -72,7 +72,7 @@ <label class="col-sm-3 control-label"> Schedule </label> - <div class="col-sm-9"> + <div id="add-check-kind" class="col-sm-9"> <label class="radio-container"> <input type="radio" name="kind" value="simple" checked> <span class="radiomark"></span> @@ -83,6 +83,11 @@ <span class="radiomark"></span> Cron </label> + <label class="radio-container"> + <input type="radio" name="kind" value="oncalendar"> + <span class="radiomark"></span> + OnCalendar + </label> </div> </div> @@ -126,7 +131,21 @@ </div> </div> - <div class="form-group create-cron-extra"> + <div class="form-group create-oncalendar-extra"> + <label for="add-check-schedule-oncalendar" class="col-sm-3 control-label"> + OnCalendar Expression(s) + </label> + <div class="col-sm-9"> + <textarea + id="add-check-schedule-oncalendar" + name="schedule" + class="form-control" + rows="3" + maxlength="100">*-*-* *:*:*</textarea> + </div> + </div> + + <div class="form-group create-cron-extra create-oncalendar-extra"> <label for="add-check-tz" class="col-sm-3 control-label"> Time Zone </label> diff --git a/templates/front/details.html b/templates/front/details.html index 72aa5ab7..9f656d19 100644 --- a/templates/front/details.html +++ b/templates/front/details.html @@ -176,7 +176,7 @@ </td> {% endif %} </tr> - {% if check.kind == "cron" %} + {% if check.kind == "cron" or check.kind == "oncalendar" %} <tr> <th>Time Zone</th> <td>