diff --git a/hc/api/migrations/0027_auto_20161205_0833.py b/hc/api/migrations/0027_auto_20161205_0833.py new file mode 100644 index 00000000..fcf8af90 --- /dev/null +++ b/hc/api/migrations/0027_auto_20161205_0833.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.1 on 2016-12-05 08:33 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0026_auto_20160415_1824'), + ] + + operations = [ + migrations.AddField( + model_name='check', + name='schedule', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='check', + name='tz', + field=models.CharField(default='UTC', max_length=36), + ), + migrations.AlterField( + model_name='channel', + name='kind', + field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps')], max_length=20), + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index 5d6e5bcd..39e3af5b 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -3,8 +3,9 @@ import hashlib import json import uuid -from datetime import timedelta as td +from datetime import datetime, timedelta as td +from croniter import croniter from django.conf import settings from django.contrib.auth.models import User from django.db import models @@ -53,6 +54,8 @@ class Check(models.Model): created = models.DateTimeField(auto_now_add=True) timeout = models.DurationField(default=DEFAULT_TIMEOUT) grace = models.DurationField(default=DEFAULT_GRACE) + schedule = models.CharField(max_length=100, blank=True) + tz = models.CharField(max_length=36, default="UTC") n_pings = models.IntegerField(default=0) last_ping = models.DateTimeField(null=True, blank=True) alert_after = models.DateTimeField(null=True, blank=True, editable=False) @@ -85,27 +88,45 @@ class Check(models.Model): return errors - def get_status(self): + def get_grace_start(self): + """ Return the datetime when grace period starts. """ + + # The common case, grace starts after timeout + if not self.schedule: + return self.last_ping + self.timeout + + # The complex case, next ping is expected based on cron schedule + with timezone.override(self.tz): + last_naive = timezone.make_naive(self.last_ping) + it = croniter(self.schedule, last_naive) + next_naive = it.get_next(datetime) + return timezone.make_aware(next_naive, is_dst=False) + + def get_status(self, now=None): + """ Return "up" if the check is up or in grace, otherwise "down". """ + if self.status in ("new", "paused"): return self.status - now = timezone.now() + if now is None: + now = timezone.now() - if self.last_ping + self.timeout + self.grace > now: - return "up" - - return "down" + return "up" if self.get_grace_start() + self.grace > now else "down" def get_alert_after(self): - return self.last_ping + self.timeout + self.grace + """ Return the datetime when check potentially goes down. """ + + return self.get_grace_start() + self.grace def in_grace_period(self): + """ Return True if check is currently in grace period. """ + if self.status in ("new", "paused"): return False - up_ends = self.last_ping + self.timeout - grace_ends = up_ends + self.grace - return up_ends < timezone.now() < grace_ends + grace_start = self.get_grace_start() + grace_end = grace_start + self.grace + return grace_start < timezone.now() < grace_end def assign_all_channels(self): if self.user: diff --git a/hc/api/tests/test_check_model.py b/hc/api/tests/test_check_model.py index 0269e0cc..6eb0b736 100644 --- a/hc/api/tests/test_check_model.py +++ b/hc/api/tests/test_check_model.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from django.test import TestCase from django.utils import timezone @@ -37,3 +37,40 @@ class CheckModelTestCase(TestCase): check.status = "paused" self.assertFalse(check.in_grace_period()) + + def test_status_works_with_cron_syntax(self): + dt = timezone.make_aware(datetime(2000, 1, 1), timezone=timezone.utc) + + # Expect ping every midnight, default grace is 1 hour + check = Check() + check.timeout = timedelta(minutes=0) + check.schedule = "0 0 * * *" + check.status = "up" + check.last_ping = dt + + # 00:30am + now = dt + timedelta(days=1, minutes=30) + self.assertEqual(check.get_status(now), "up") + + # 1:30am + now = dt + timedelta(days=1, minutes=90) + self.assertEqual(check.get_status(now), "down") + + def test_status_works_with_timezone(self): + dt = timezone.make_aware(datetime(2000, 1, 1), timezone=timezone.utc) + + # Expect ping every day at 10am, default grace is 1 hour + check = Check() + check.timeout = timedelta(minutes=0) + check.schedule = "0 10 * * *" + check.status = "up" + check.last_ping = dt + check.tz = "Australia/Brisbane" # UTC+10 + + # 10:30am + now = dt + timedelta(days=1, minutes=30) + self.assertEqual(check.get_status(now), "up") + + # 11:30am + now = dt + timedelta(days=1, minutes=90) + self.assertEqual(check.get_status(now), "down") diff --git a/requirements.txt b/requirements.txt index 928fa032..d6b4d835 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +croniter django-appconf==1.0.1 django-ses-backend==0.1.1 Django==1.10.1 @@ -5,4 +6,5 @@ django_compressor==2.1 djmail==0.11.0 premailer==2.9.6 psycopg2==2.6.1 +pytz==2016.7 requests==2.9.1