From 010bbc950730d55cd1298d1c9e1623ea726339f0 Mon Sep 17 00:00:00 2001
From: James Kirsop
Date: Fri, 27 Mar 2020 09:30:26 +1100
Subject: [PATCH 01/13] Sample work for review
---
hc/api/models.py | 26 +++++++++++++++++++++++++-
hc/api/views.py | 2 ++
2 files changed, 27 insertions(+), 1 deletion(-)
diff --git a/hc/api/models.py b/hc/api/models.py
index 38fc2afd..16dba4a2 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -16,6 +16,7 @@ from hc.api import transports
from hc.lib import emails
from hc.lib.date import month_boundaries
import pytz
+import re
STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New"), ("paused", "Paused"))
DEFAULT_TIMEOUT = td(days=1)
@@ -205,7 +206,7 @@ class Check(models.Model):
code_half = self.code.hex[:16]
return hashlib.sha1(code_half.encode()).hexdigest()
- def to_dict(self, readonly=False):
+ def to_dict(self, readonly=False, history=None):
result = {
"name": self.name,
@@ -221,6 +222,29 @@ class Check(models.Model):
if self.last_duration:
result["last_duration"] = int(self.last_duration.total_seconds())
+ if history:
+ split = re.split(r'(h|d|w)$',history,maxsplit=0)
+ if len(split) == 3: # re.split should return a list of 3 items if the parameter is set correctly
+ zone = pytz.timezone(self.tz)
+ current_now = datetime.now(tz=zone)
+
+ if split[1] == 'd':
+ cutoff = current_now - td(days=int(split[0]))
+ elif split[1] == 'h':
+ cutoff = current_now - td(hours=int(split[0]))
+ elif split[1] == 'w':
+ cutoff = current_now - td(weeks=int(split[0]))
+
+ pings = Ping.objects.filter(owner=self, created__gte=cutoff).order_by("-id")#[:limit]
+ pings = list(pings)
+
+ alerts = Notification.objects.select_related("channel").filter(
+ owner=self, check_status="down", created__gt=cutoff
+ )
+
+ events = pings + list(alerts)
+ events.sort(key=lambda el: el.created, reverse=True)
+ result['history'] = list(map(lambda x: {'timestamp':x.created,'status':x.kind}, events))
if readonly:
result["unique_key"] = self.unique_key
else:
diff --git a/hc/api/views.py b/hc/api/views.py
index fed66d6c..9e2ecb6a 100644
--- a/hc/api/views.py
+++ b/hc/api/views.py
@@ -185,6 +185,8 @@ def get_check(request, code):
if check.project != request.project:
return HttpResponseForbidden()
+ if 'history' in request.GET:
+ return JsonResponse(check.to_dict(readonly=request.readonly, history=request.GET['history']))
return JsonResponse(check.to_dict(readonly=request.readonly))
From 74f4744c62ced0c8649613b3932da07084b6abbe Mon Sep 17 00:00:00 2001
From: James Kirsop
Date: Fri, 27 Mar 2020 14:19:57 +1100
Subject: [PATCH 02/13] Implementation of history using Flips model statuses
for a check
---
hc/api/models.py | 15 +++++----------
1 file changed, 5 insertions(+), 10 deletions(-)
diff --git a/hc/api/models.py b/hc/api/models.py
index 16dba4a2..1efe7487 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -235,16 +235,11 @@ class Check(models.Model):
elif split[1] == 'w':
cutoff = current_now - td(weeks=int(split[0]))
- pings = Ping.objects.filter(owner=self, created__gte=cutoff).order_by("-id")#[:limit]
- pings = list(pings)
-
- alerts = Notification.objects.select_related("channel").filter(
- owner=self, check_status="down", created__gt=cutoff
- )
-
- events = pings + list(alerts)
- events.sort(key=lambda el: el.created, reverse=True)
- result['history'] = list(map(lambda x: {'timestamp':x.created,'status':x.kind}, events))
+ flips = Flip.objects.select_related("owner").filter(
+ owner=self, new_status__in=("down","up"), created__gt=cutoff
+ ).order_by("created")
+ dictStatus = {"up":1,"down":0}
+ result['history'] = list(map(lambda x: {'timestamp':x.created,'status':dictStatus[x.new_status]}, flips))
if readonly:
result["unique_key"] = self.unique_key
else:
From aaadf6031f81ef44d347fc0a6dc7e77d1fe99887 Mon Sep 17 00:00:00 2001
From: James Kirsop
Date: Fri, 27 Mar 2020 09:30:26 +1100
Subject: [PATCH 03/13] Sample work for review
---
hc/api/models.py | 26 +++++++++++++++++++++++++-
hc/api/views.py | 2 ++
2 files changed, 27 insertions(+), 1 deletion(-)
diff --git a/hc/api/models.py b/hc/api/models.py
index 3962a1ad..8e9ccf64 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -16,6 +16,7 @@ from hc.api import transports
from hc.lib import emails
from hc.lib.date import month_boundaries
import pytz
+import re
STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New"), ("paused", "Paused"))
DEFAULT_TIMEOUT = td(days=1)
@@ -206,7 +207,7 @@ class Check(models.Model):
code_half = self.code.hex[:16]
return hashlib.sha1(code_half.encode()).hexdigest()
- def to_dict(self, readonly=False):
+ def to_dict(self, readonly=False, history=None):
result = {
"name": self.name,
@@ -223,6 +224,29 @@ class Check(models.Model):
if self.last_duration:
result["last_duration"] = int(self.last_duration.total_seconds())
+ if history:
+ split = re.split(r'(h|d|w)$',history,maxsplit=0)
+ if len(split) == 3: # re.split should return a list of 3 items if the parameter is set correctly
+ zone = pytz.timezone(self.tz)
+ current_now = datetime.now(tz=zone)
+
+ if split[1] == 'd':
+ cutoff = current_now - td(days=int(split[0]))
+ elif split[1] == 'h':
+ cutoff = current_now - td(hours=int(split[0]))
+ elif split[1] == 'w':
+ cutoff = current_now - td(weeks=int(split[0]))
+
+ pings = Ping.objects.filter(owner=self, created__gte=cutoff).order_by("-id")#[:limit]
+ pings = list(pings)
+
+ alerts = Notification.objects.select_related("channel").filter(
+ owner=self, check_status="down", created__gt=cutoff
+ )
+
+ events = pings + list(alerts)
+ events.sort(key=lambda el: el.created, reverse=True)
+ result['history'] = list(map(lambda x: {'timestamp':x.created,'status':x.kind}, events))
if readonly:
result["unique_key"] = self.unique_key
else:
diff --git a/hc/api/views.py b/hc/api/views.py
index 9c876fc2..ada86f5a 100644
--- a/hc/api/views.py
+++ b/hc/api/views.py
@@ -189,6 +189,8 @@ def get_check(request, code):
if check.project_id != request.project.id:
return HttpResponseForbidden()
+ if 'history' in request.GET:
+ return JsonResponse(check.to_dict(readonly=request.readonly, history=request.GET['history']))
return JsonResponse(check.to_dict(readonly=request.readonly))
From bc6ccd55b33a1856fc07f0b1cf1c51494c56bfec Mon Sep 17 00:00:00 2001
From: James Kirsop
Date: Fri, 27 Mar 2020 14:19:57 +1100
Subject: [PATCH 04/13] Implementation of history using Flips model statuses
for a check
---
hc/api/models.py | 15 +++++----------
1 file changed, 5 insertions(+), 10 deletions(-)
diff --git a/hc/api/models.py b/hc/api/models.py
index 8e9ccf64..5271d911 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -237,16 +237,11 @@ class Check(models.Model):
elif split[1] == 'w':
cutoff = current_now - td(weeks=int(split[0]))
- pings = Ping.objects.filter(owner=self, created__gte=cutoff).order_by("-id")#[:limit]
- pings = list(pings)
-
- alerts = Notification.objects.select_related("channel").filter(
- owner=self, check_status="down", created__gt=cutoff
- )
-
- events = pings + list(alerts)
- events.sort(key=lambda el: el.created, reverse=True)
- result['history'] = list(map(lambda x: {'timestamp':x.created,'status':x.kind}, events))
+ flips = Flip.objects.select_related("owner").filter(
+ owner=self, new_status__in=("down","up"), created__gt=cutoff
+ ).order_by("created")
+ dictStatus = {"up":1,"down":0}
+ result['history'] = list(map(lambda x: {'timestamp':x.created,'status':dictStatus[x.new_status]}, flips))
if readonly:
result["unique_key"] = self.unique_key
else:
From 4b1b2329594ea47a45b6576ac1da0810f5e48332 Mon Sep 17 00:00:00 2001
From: James Kirsop
Date: Fri, 12 Jun 2020 09:16:20 +1000
Subject: [PATCH 05/13] Chnange 'status' field in response to 'up'
---
hc/api/models.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/hc/api/models.py b/hc/api/models.py
index 5271d911..a6f7d462 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -241,7 +241,7 @@ class Check(models.Model):
owner=self, new_status__in=("down","up"), created__gt=cutoff
).order_by("created")
dictStatus = {"up":1,"down":0}
- result['history'] = list(map(lambda x: {'timestamp':x.created,'status':dictStatus[x.new_status]}, flips))
+ result['history'] = list(map(lambda x: {'timestamp':x.created,'up':dictStatus[x.new_status]}, flips))
if readonly:
result["unique_key"] = self.unique_key
else:
From 90d42468482b94867d3ebefa91468b1ed5bf062d Mon Sep 17 00:00:00 2001
From: James Kirsop
Date: Fri, 12 Jun 2020 13:39:03 +1000
Subject: [PATCH 06/13] Second interation of this
---
hc/api/models.py | 20 +----------
hc/api/urls.py | 2 ++
hc/api/views.py | 53 +++++++++++++++++++++++++++++
templates/docs/api.md | 77 +++++++++++++++++++++++++++++++++++++++++++
4 files changed, 133 insertions(+), 19 deletions(-)
diff --git a/hc/api/models.py b/hc/api/models.py
index a6f7d462..c4b9f6e3 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -207,7 +207,7 @@ class Check(models.Model):
code_half = self.code.hex[:16]
return hashlib.sha1(code_half.encode()).hexdigest()
- def to_dict(self, readonly=False, history=None):
+ def to_dict(self, readonly=False):
result = {
"name": self.name,
@@ -224,24 +224,6 @@ class Check(models.Model):
if self.last_duration:
result["last_duration"] = int(self.last_duration.total_seconds())
- if history:
- split = re.split(r'(h|d|w)$',history,maxsplit=0)
- if len(split) == 3: # re.split should return a list of 3 items if the parameter is set correctly
- zone = pytz.timezone(self.tz)
- current_now = datetime.now(tz=zone)
-
- if split[1] == 'd':
- cutoff = current_now - td(days=int(split[0]))
- elif split[1] == 'h':
- cutoff = current_now - td(hours=int(split[0]))
- elif split[1] == 'w':
- cutoff = current_now - td(weeks=int(split[0]))
-
- flips = Flip.objects.select_related("owner").filter(
- owner=self, new_status__in=("down","up"), created__gt=cutoff
- ).order_by("created")
- dictStatus = {"up":1,"down":0}
- result['history'] = list(map(lambda x: {'timestamp':x.created,'up':dictStatus[x.new_status]}, flips))
if readonly:
result["unique_key"] = self.unique_key
else:
diff --git a/hc/api/urls.py b/hc/api/urls.py
index 422ee154..d978e35f 100644
--- a/hc/api/urls.py
+++ b/hc/api/urls.py
@@ -38,6 +38,8 @@ urlpatterns = [
path("api/v1/checks//pause", views.pause, name="hc-api-pause"),
path("api/v1/notifications//bounce", views.bounce, name="hc-api-bounce"),
path("api/v1/checks//pings/", views.pings, name="hc-api-pings"),
+ path("api/v1/checks//flips/", views.flips, name="hc-api-flips"),
+ path("api/v1/checks//flips/", views.get_flips_by_unique_key),
path("api/v1/channels/", views.channels),
path(
"badge///.",
diff --git a/hc/api/views.py b/hc/api/views.py
index ada86f5a..b83dab56 100644
--- a/hc/api/views.py
+++ b/hc/api/views.py
@@ -1,4 +1,5 @@
from datetime import timedelta as td
+from datetime import datetime
import time
import uuid
@@ -8,6 +9,7 @@ from django.http import (
HttpResponse,
HttpResponseForbidden,
HttpResponseNotFound,
+ HttpResponseBadRequest,
JsonResponse,
)
from django.shortcuts import get_object_or_404
@@ -293,6 +295,57 @@ def pings(request, code):
return JsonResponse({"pings": dicts})
+@cors("GET")
+@csrf_exempt
+@validate_json()
+@authorize_read
+def flips(request, code):
+ check = get_object_or_404(Check, code=code)
+ if check.project_id != request.project.id:
+ return HttpResponseForbidden()
+
+ if any(x in request.GET for x in ('start','end')) and 'seconds' in request.GET:
+ return HttpResponseBadRequest()
+
+ history_start = None
+ history_end = datetime.now()
+
+ if 'start' in request.GET:
+ history_start = datetime.fromtimestamp(int(request.GET['start']))
+ if 'end' in request.GET:
+ history_end = datetime.fromtimestamp(int(request.GET['end']))
+
+ if 'seconds' in request.GET:
+ history_start = datetime.now()-td(seconds=int(request.GET['seconds']))
+ elif not history_start:
+ history_start = datetime.now()-td(seconds=3600)
+
+ flips = Flip.objects.select_related("owner").filter(
+ owner=check, new_status__in=("down","up"),
+ created__gt=history_start,
+ created__lt=history_end
+ ).order_by("created")
+ dictStatus = {"up":1,"down":0}
+
+ return JsonResponse({"flips": list(map(lambda x: {'timestamp':x.created,'up':dictStatus[x.new_status]}, flips))})
+
+ # return JsonResponse(check.to_dict(
+ # readonly=request.readonly,
+ # history=(
+ # history_start,history_end
+ # )
+ # ))
+
+@cors("GET")
+@csrf_exempt
+@validate_json()
+@authorize_read
+def get_flips_by_unique_key(request, unique_key):
+ checks = Check.objects.filter(project=request.project.id)
+ for check in checks:
+ if check.unique_key == unique_key:
+ return flips(request,check.code)
+ return HttpResponseNotFound()
@never_cache
@cors("GET")
diff --git a/templates/docs/api.md b/templates/docs/api.md
index dd1ca5b5..8b2ab1e4 100644
--- a/templates/docs/api.md
+++ b/templates/docs/api.md
@@ -746,6 +746,83 @@ curl SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/pings/ \
```
+## Get a list of check's flips {: #list-flips .rule }
+
+`GET SITE_ROOT/api/v1/checks//flips/` or `GET SITE_ROOT/api/v1/checks//flips/`
+
+Returns a list of flips this check has experienced. A flip is a change of status (up, or down).
+
+This endpoint returns the status of a check for the period of time passed according to the below parameters. If no parameters are passed, the default is to return flips occuring in the previous 3600 seconds (`/?seconds=3600`), which is the last hour.
+
+### Query String Parameters
+
+Either the seconds or the start (and end) parameters can be passed. Passing both the seconds parameter and the start/end parameters will return a 400 error (see below).
+
+seconds=<value>
+: Filters the checks, and returns the flip history in the last `n` seconds
+
+ Example:
+
+ `SITE_ROOT/api/v1/checks//flips/?seconds=3600`
+
+start=<value>&end=<value>
+: Filters the checks, and returns the flip history between the start and end timestamps.
+
+ If provided, both values must be unix timestamps. The `end` parameter is optional and defaults to the timestamp of the current date and time.
+
+ Example:
+
+ `SITE_ROOT/api/v1/checks//flips/?seconds=3600`
+
+
+### Response Codes
+
+200 OK
+: The request succeeded.
+
+400 Bad Request
+: Both a seconds and a start or end query string parameter has been passed, which is unsupported.
+
+401 Unauthorized
+: The API key is either missing or invalid.
+
+403 Forbidden
+: Access denied, wrong API key.
+
+404 Not Found
+: The specified check does not exist.
+
+### Example Request
+
+```bash
+curl SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/flips/ \
+ --header "X-Api-Key: your-api-key"
+```
+
+### Example Response
+
+```json
+{
+ "name": "My First Check",
+ "tags": "",
+ "desc": "",
+ "grace": 3600,
+ "n_pings": 2,
+ "status": "up",
+ "last_ping": "2020-06-12T02:18:46+00:00",
+ "next_ping": "2020-06-13T02:18:46+00:00",
+ "manual_resume": false,
+ "history": [
+ {
+ "timestamp": "2020-03-23T23:30:18.767Z",
+ "up": 1
+ }
+ ],
+ "unique_key": "e855898bebff1756cde7c571319d877d07a38dab",
+ "timeout": 86400
+}
+```
+
## Get a List of Existing Integrations {: #list-channels .rule }
`GET SITE_ROOT/api/v1/channels/`
From c5c4e0f782ed987f6b5d3e8a5d8640cee1228ae3 Mon Sep 17 00:00:00 2001
From: James Kirsop
Date: Fri, 12 Jun 2020 17:42:45 +1000
Subject: [PATCH 07/13] Returning all historical flips if no parameters are
passed
---
hc/api/views.py | 46 +++++++++++++++++++++++++---------------------
1 file changed, 25 insertions(+), 21 deletions(-)
diff --git a/hc/api/views.py b/hc/api/views.py
index b83dab56..67655050 100644
--- a/hc/api/views.py
+++ b/hc/api/views.py
@@ -304,27 +304,31 @@ def flips(request, code):
if check.project_id != request.project.id:
return HttpResponseForbidden()
- if any(x in request.GET for x in ('start','end')) and 'seconds' in request.GET:
- return HttpResponseBadRequest()
-
- history_start = None
- history_end = datetime.now()
-
- if 'start' in request.GET:
- history_start = datetime.fromtimestamp(int(request.GET['start']))
- if 'end' in request.GET:
- history_end = datetime.fromtimestamp(int(request.GET['end']))
-
- if 'seconds' in request.GET:
- history_start = datetime.now()-td(seconds=int(request.GET['seconds']))
- elif not history_start:
- history_start = datetime.now()-td(seconds=3600)
-
- flips = Flip.objects.select_related("owner").filter(
- owner=check, new_status__in=("down","up"),
- created__gt=history_start,
- created__lt=history_end
- ).order_by("created")
+ if all(x not in request.GET for x in ('start','end','seconds')):
+ flips = Flip.objects.select_related("owner").filter(
+ owner=check, new_status__in=("down","up"),
+ ).order_by("created")
+ else:
+ if any(x in request.GET for x in ('start','end')) and 'seconds' in request.GET:
+ return HttpResponseBadRequest()
+
+ history_start = None
+ history_end = datetime.now()
+
+ if 'start' in request.GET:
+ history_start = datetime.fromtimestamp(int(request.GET['start']))
+ if 'end' in request.GET:
+ history_end = datetime.fromtimestamp(int(request.GET['end']))
+
+ if 'seconds' in request.GET:
+ history_start = datetime.now()-td(seconds=int(request.GET['seconds']))
+
+ flips = Flip.objects.select_related("owner").filter(
+ owner=check, new_status__in=("down","up"),
+ created__gt=history_start,
+ created__lt=history_end
+ ).order_by("created")
+
dictStatus = {"up":1,"down":0}
return JsonResponse({"flips": list(map(lambda x: {'timestamp':x.created,'up':dictStatus[x.new_status]}, flips))})
From 368d7a4fec374f5cec57e485902191bc231c8a87 Mon Sep 17 00:00:00 2001
From: James Kirsop
Date: Mon, 15 Jun 2020 13:15:57 +1000
Subject: [PATCH 08/13] Commit with requested changes and tests
---
hc/api/models.py | 3 ++
hc/api/tests/test_get_flips.py | 56 ++++++++++++++++++++++++++++++++++
hc/api/urls.py | 4 +--
hc/api/views.py | 53 +++++++++++++-------------------
4 files changed, 82 insertions(+), 34 deletions(-)
create mode 100644 hc/api/tests/test_get_flips.py
diff --git a/hc/api/models.py b/hc/api/models.py
index 5271d911..851efcda 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -778,6 +778,9 @@ class Flip(models.Model):
condition=models.Q(processed=None),
)
]
+
+ def to_dict(self):
+ return {"timestamp": self.created, "up": 1 if self.new_status == "up" else 0}
def send_alerts(self):
if self.new_status == "up" and self.old_status in ("new", "paused"):
diff --git a/hc/api/tests/test_get_flips.py b/hc/api/tests/test_get_flips.py
new file mode 100644
index 00000000..e6e5aa17
--- /dev/null
+++ b/hc/api/tests/test_get_flips.py
@@ -0,0 +1,56 @@
+from datetime import timedelta as td
+from datetime import datetime as dt
+from datetime import timezone
+
+from hc.api.models import Check, Flip
+from hc.test import BaseTestCase
+
+
+class GetFlipsTestCase(BaseTestCase):
+ def setUp(self):
+ super(GetFlipsTestCase, self).setUp()
+
+ self.a1 = Check(project=self.project, name="Alice 1")
+ self.a1.timeout = td(seconds=3600)
+ self.a1.grace = td(seconds=900)
+ self.a1.n_pings = 0
+ self.a1.status = "new"
+ self.a1.tags = "a1-tag a1-additional-tag"
+ self.a1.desc = "This is description"
+ self.a1.save()
+
+ self.url = "/api/v1/checks/%s/flips/" % self.a1.code
+
+ def get(self, api_key="X" * 32):
+ return self.client.get(self.url, HTTP_X_API_KEY=api_key)
+
+ def test_it_works(self):
+ self.f1 = Flip(
+ owner=self.a1,
+ created=dt(2020,6,1,12,24,32,tzinfo=timezone.utc),
+ old_status='new',
+ new_status='up',
+ )
+ self.f1.save()
+
+ r = self.get()
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r["Access-Control-Allow-Origin"], "*")
+
+ doc = r.json()
+ self.assertEqual(len(doc["flips"]), 1)
+
+ flip = doc["flips"][0]
+ self.assertEqual(flip["timestamp"], dt(2020,6,1,12,24,32,tzinfo=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))
+ self.assertEqual(flip["up"], 1)
+
+ def test_readonly_key_is_allowed(self):
+ self.project.api_key_readonly = "R" * 32
+ self.project.save()
+
+ r = self.get(api_key=self.project.api_key_readonly)
+ self.assertEqual(r.status_code, 200)
+
+ def test_it_rejects_post(self):
+ r = self.client.post(self.url, HTTP_X_API_KEY="X" * 32)
+ self.assertEqual(r.status_code, 405)
diff --git a/hc/api/urls.py b/hc/api/urls.py
index d978e35f..09be5cfb 100644
--- a/hc/api/urls.py
+++ b/hc/api/urls.py
@@ -38,8 +38,8 @@ urlpatterns = [
path("api/v1/checks//pause", views.pause, name="hc-api-pause"),
path("api/v1/notifications//bounce", views.bounce, name="hc-api-bounce"),
path("api/v1/checks//pings/", views.pings, name="hc-api-pings"),
- path("api/v1/checks//flips/", views.flips, name="hc-api-flips"),
- path("api/v1/checks//flips/", views.get_flips_by_unique_key),
+ path("api/v1/checks//flips/", views.flips_by_uuid, name="hc-api-flips"),
+ path("api/v1/checks//flips/", views.flips_by_unique_key),
path("api/v1/channels/", views.channels),
path(
"badge///.",
diff --git a/hc/api/views.py b/hc/api/views.py
index 67655050..488859ab 100644
--- a/hc/api/views.py
+++ b/hc/api/views.py
@@ -190,9 +190,6 @@ def get_check(request, code):
check = get_object_or_404(Check, code=code)
if check.project_id != request.project.id:
return HttpResponseForbidden()
-
- if 'history' in request.GET:
- return JsonResponse(check.to_dict(readonly=request.readonly, history=request.GET['history']))
return JsonResponse(check.to_dict(readonly=request.readonly))
@@ -295,60 +292,52 @@ def pings(request, code):
return JsonResponse({"pings": dicts})
-@cors("GET")
-@csrf_exempt
-@validate_json()
-@authorize_read
-def flips(request, code):
- check = get_object_or_404(Check, code=code)
+def flips(request, check):
if check.project_id != request.project.id:
return HttpResponseForbidden()
if all(x not in request.GET for x in ('start','end','seconds')):
- flips = Flip.objects.select_related("owner").filter(
+ flips = Flip.objects.filter(
owner=check, new_status__in=("down","up"),
).order_by("created")
else:
- if any(x in request.GET for x in ('start','end')) and 'seconds' in request.GET:
+ if "seconds" in request.GET and ("start" in request.GET or "end" in request.GET):
return HttpResponseBadRequest()
- history_start = None
- history_end = datetime.now()
+ flips = Flip.objects.filter(
+ owner=check, new_status__in=("down","up"))
if 'start' in request.GET:
- history_start = datetime.fromtimestamp(int(request.GET['start']))
+ flips = flips.filter(created_gt=datetime.fromtimestamp(int(request.GET['start'])))
+
if 'end' in request.GET:
- history_end = datetime.fromtimestamp(int(request.GET['end']))
+ flips = flips.filter(created__lt=datetime.fromtimestamp(int(request.GET['end'])))
if 'seconds' in request.GET:
- history_start = datetime.now()-td(seconds=int(request.GET['seconds']))
-
- flips = Flip.objects.select_related("owner").filter(
- owner=check, new_status__in=("down","up"),
- created__gt=history_start,
- created__lt=history_end
- ).order_by("created")
+ flips = flips.filter(created_gt=datetime.now()-td(seconds=int(request.GET['seconds'])))
- dictStatus = {"up":1,"down":0}
+ flips = flips.order_by("created")
+
- return JsonResponse({"flips": list(map(lambda x: {'timestamp':x.created,'up':dictStatus[x.new_status]}, flips))})
+ return JsonResponse({"flips": [flip.to_dict() for flip in flips]})
- # return JsonResponse(check.to_dict(
- # readonly=request.readonly,
- # history=(
- # history_start,history_end
- # )
- # ))
+@cors("GET")
+@csrf_exempt
+@validate_json()
+@authorize_read
+def flips_by_uuid(request,code):
+ check = get_object_or_404(Check, code=code)
+ return flips(request,check)
@cors("GET")
@csrf_exempt
@validate_json()
@authorize_read
-def get_flips_by_unique_key(request, unique_key):
+def flips_by_unique_key(request, unique_key):
checks = Check.objects.filter(project=request.project.id)
for check in checks:
if check.unique_key == unique_key:
- return flips(request,check.code)
+ return flips(request,check)
return HttpResponseNotFound()
@never_cache
From a90f8a3a56538411753a147fb788610676a3ae81 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?=
Date: Mon, 15 Jun 2020 12:17:15 +0300
Subject: [PATCH 09/13] Remove unused code.
---
hc/api/models.py | 22 ++--------------------
1 file changed, 2 insertions(+), 20 deletions(-)
diff --git a/hc/api/models.py b/hc/api/models.py
index 851efcda..7616f648 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -207,7 +207,7 @@ class Check(models.Model):
code_half = self.code.hex[:16]
return hashlib.sha1(code_half.encode()).hexdigest()
- def to_dict(self, readonly=False, history=None):
+ def to_dict(self, readonly=False):
result = {
"name": self.name,
@@ -224,24 +224,6 @@ class Check(models.Model):
if self.last_duration:
result["last_duration"] = int(self.last_duration.total_seconds())
- if history:
- split = re.split(r'(h|d|w)$',history,maxsplit=0)
- if len(split) == 3: # re.split should return a list of 3 items if the parameter is set correctly
- zone = pytz.timezone(self.tz)
- current_now = datetime.now(tz=zone)
-
- if split[1] == 'd':
- cutoff = current_now - td(days=int(split[0]))
- elif split[1] == 'h':
- cutoff = current_now - td(hours=int(split[0]))
- elif split[1] == 'w':
- cutoff = current_now - td(weeks=int(split[0]))
-
- flips = Flip.objects.select_related("owner").filter(
- owner=self, new_status__in=("down","up"), created__gt=cutoff
- ).order_by("created")
- dictStatus = {"up":1,"down":0}
- result['history'] = list(map(lambda x: {'timestamp':x.created,'status':dictStatus[x.new_status]}, flips))
if readonly:
result["unique_key"] = self.unique_key
else:
@@ -778,7 +760,7 @@ class Flip(models.Model):
condition=models.Q(processed=None),
)
]
-
+
def to_dict(self):
return {"timestamp": self.created, "up": 1 if self.new_status == "up" else 0}
From 60d1c6e2a31a8815d18f83c066f15cc7b475502c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?=
Date: Mon, 15 Jun 2020 12:20:07 +0300
Subject: [PATCH 10/13] Format timestamp as ISO 8601 without microseconds, same
as elsewhere.
---
hc/api/models.py | 5 ++++-
hc/api/tests/test_get_flips.py | 12 ++++++------
2 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/hc/api/models.py b/hc/api/models.py
index 7616f648..aceb5b83 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -762,7 +762,10 @@ class Flip(models.Model):
]
def to_dict(self):
- return {"timestamp": self.created, "up": 1 if self.new_status == "up" else 0}
+ return {
+ "timestamp": isostring(self.created),
+ "up": 1 if self.new_status == "up" else 0,
+ }
def send_alerts(self):
if self.new_status == "up" and self.old_status in ("new", "paused"):
diff --git a/hc/api/tests/test_get_flips.py b/hc/api/tests/test_get_flips.py
index e6e5aa17..ecc19c14 100644
--- a/hc/api/tests/test_get_flips.py
+++ b/hc/api/tests/test_get_flips.py
@@ -25,13 +25,12 @@ class GetFlipsTestCase(BaseTestCase):
return self.client.get(self.url, HTTP_X_API_KEY=api_key)
def test_it_works(self):
- self.f1 = Flip(
+ Flip.objects.create(
owner=self.a1,
- created=dt(2020,6,1,12,24,32,tzinfo=timezone.utc),
- old_status='new',
- new_status='up',
+ created=dt(2020, 6, 1, 12, 24, 32, 123000, tzinfo=timezone.utc),
+ old_status="new",
+ new_status="up",
)
- self.f1.save()
r = self.get()
self.assertEqual(r.status_code, 200)
@@ -41,7 +40,8 @@ class GetFlipsTestCase(BaseTestCase):
self.assertEqual(len(doc["flips"]), 1)
flip = doc["flips"][0]
- self.assertEqual(flip["timestamp"], dt(2020,6,1,12,24,32,tzinfo=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))
+ # Microseconds (123000) should be stripped out
+ self.assertEqual(flip["timestamp"], "2020-06-01T12:24:32+00:00")
self.assertEqual(flip["up"], 1)
def test_readonly_key_is_allowed(self):
From 832580f343f5eff38217b39059d6b4da2d5d565e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?=
Date: Mon, 15 Jun 2020 13:08:17 +0300
Subject: [PATCH 11/13] Simplify hc.api.views.flips, add validation and more
tests.
---
hc/api/forms.py | 28 +++++++++++++++++++++++
hc/api/models.py | 1 -
hc/api/tests/test_get_flips.py | 33 ++++++++++++++++++++++-----
hc/api/views.py | 41 ++++++++++++++++------------------
4 files changed, 74 insertions(+), 29 deletions(-)
create mode 100644 hc/api/forms.py
diff --git a/hc/api/forms.py b/hc/api/forms.py
new file mode 100644
index 00000000..1d983b3f
--- /dev/null
+++ b/hc/api/forms.py
@@ -0,0 +1,28 @@
+from datetime import datetime as dt
+
+from django import forms
+from django.core.exceptions import ValidationError
+import pytz
+
+
+class TimestampField(forms.Field):
+ def to_python(self, value):
+ if value is None:
+ return None
+
+ try:
+ value_int = int(value)
+ except ValueError:
+ raise ValidationError(message="Must be an integer")
+
+ # 10000000000 is year 2286 (a sanity check)
+ if value_int < 0 or value_int > 10000000000:
+ raise ValidationError(message="Out of bounds")
+
+ return dt.fromtimestamp(value_int, pytz.UTC)
+
+
+class FlipsFiltersForm(forms.Form):
+ start = TimestampField(required=False)
+ end = TimestampField(required=False)
+ seconds = forms.IntegerField(required=False, min_value=0, max_value=31536000)
diff --git a/hc/api/models.py b/hc/api/models.py
index aceb5b83..ba5aa13f 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -16,7 +16,6 @@ from hc.api import transports
from hc.lib import emails
from hc.lib.date import month_boundaries
import pytz
-import re
STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New"), ("paused", "Paused"))
DEFAULT_TIMEOUT = td(days=1)
diff --git a/hc/api/tests/test_get_flips.py b/hc/api/tests/test_get_flips.py
index ecc19c14..ac4b2ea6 100644
--- a/hc/api/tests/test_get_flips.py
+++ b/hc/api/tests/test_get_flips.py
@@ -19,12 +19,6 @@ class GetFlipsTestCase(BaseTestCase):
self.a1.desc = "This is description"
self.a1.save()
- self.url = "/api/v1/checks/%s/flips/" % self.a1.code
-
- def get(self, api_key="X" * 32):
- return self.client.get(self.url, HTTP_X_API_KEY=api_key)
-
- def test_it_works(self):
Flip.objects.create(
owner=self.a1,
created=dt(2020, 6, 1, 12, 24, 32, 123000, tzinfo=timezone.utc),
@@ -32,6 +26,13 @@ class GetFlipsTestCase(BaseTestCase):
new_status="up",
)
+ self.url = "/api/v1/checks/%s/flips/" % self.a1.code
+
+ def get(self, api_key="X" * 32, qs=""):
+ return self.client.get(self.url + qs, HTTP_X_API_KEY=api_key)
+
+ def test_it_works(self):
+
r = self.get()
self.assertEqual(r.status_code, 200)
self.assertEqual(r["Access-Control-Allow-Origin"], "*")
@@ -54,3 +55,23 @@ class GetFlipsTestCase(BaseTestCase):
def test_it_rejects_post(self):
r = self.client.post(self.url, HTTP_X_API_KEY="X" * 32)
self.assertEqual(r.status_code, 405)
+
+ def test_it_rejects_non_integer_start(self):
+ r = self.get(qs="?start=abc")
+ self.assertEqual(r.status_code, 400)
+
+ def test_it_rejects_negative_start(self):
+ r = self.get(qs="?start=-123")
+ self.assertEqual(r.status_code, 400)
+
+ def test_it_rejects_huge_start(self):
+ r = self.get(qs="?start=12345678901234567890")
+ self.assertEqual(r.status_code, 400)
+
+ def test_it_rejects_negative_seconds(self):
+ r = self.get(qs="?seconds=-123")
+ self.assertEqual(r.status_code, 400)
+
+ def test_it_rejects_huge_seconds(self):
+ r = self.get(qs="?seconds=12345678901234567890")
+ self.assertEqual(r.status_code, 400)
diff --git a/hc/api/views.py b/hc/api/views.py
index 488859ab..478f4fd2 100644
--- a/hc/api/views.py
+++ b/hc/api/views.py
@@ -1,5 +1,4 @@
from datetime import timedelta as td
-from datetime import datetime
import time
import uuid
@@ -21,6 +20,7 @@ from django.views.decorators.http import require_POST
from hc.accounts.models import Profile
from hc.api import schemas
from hc.api.decorators import authorize, authorize_read, cors, validate_json
+from hc.api.forms import FlipsFiltersForm
from hc.api.models import MAX_DELTA, Flip, Channel, Check, Notification, Ping
from hc.lib.badges import check_signature, get_badge_svg
@@ -292,42 +292,38 @@ def pings(request, code):
return JsonResponse({"pings": dicts})
+
def flips(request, check):
if check.project_id != request.project.id:
return HttpResponseForbidden()
- if all(x not in request.GET for x in ('start','end','seconds')):
- flips = Flip.objects.filter(
- owner=check, new_status__in=("down","up"),
- ).order_by("created")
- else:
- if "seconds" in request.GET and ("start" in request.GET or "end" in request.GET):
- return HttpResponseBadRequest()
+ form = FlipsFiltersForm(request.GET)
+ if not form.is_valid():
+ return HttpResponseBadRequest()
- flips = Flip.objects.filter(
- owner=check, new_status__in=("down","up"))
+ flips = Flip.objects.filter(owner=check).order_by("-id")
- if 'start' in request.GET:
- flips = flips.filter(created_gt=datetime.fromtimestamp(int(request.GET['start'])))
-
- if 'end' in request.GET:
- flips = flips.filter(created__lt=datetime.fromtimestamp(int(request.GET['end'])))
+ if form.cleaned_data["start"]:
+ flips = flips.filter(created__gte=form.cleaned_data["start"])
- if 'seconds' in request.GET:
- flips = flips.filter(created_gt=datetime.now()-td(seconds=int(request.GET['seconds'])))
+ if form.cleaned_data["end"]:
+ flips = flips.filter(created__lt=form.cleaned_data["end"])
- flips = flips.order_by("created")
-
+ if form.cleaned_data["seconds"]:
+ threshold = timezone.now() - td(seconds=form.cleaned_data["seconds"])
+ flips = flips.filter(created__gte=threshold)
return JsonResponse({"flips": [flip.to_dict() for flip in flips]})
+
@cors("GET")
@csrf_exempt
@validate_json()
@authorize_read
-def flips_by_uuid(request,code):
+def flips_by_uuid(request, code):
check = get_object_or_404(Check, code=code)
- return flips(request,check)
+ return flips(request, check)
+
@cors("GET")
@csrf_exempt
@@ -337,9 +333,10 @@ def flips_by_unique_key(request, unique_key):
checks = Check.objects.filter(project=request.project.id)
for check in checks:
if check.unique_key == unique_key:
- return flips(request,check)
+ return flips(request, check)
return HttpResponseNotFound()
+
@never_cache
@cors("GET")
def badge(request, badge_key, signature, tag, fmt="svg"):
From c3d8ee096526106073eb8289ea1f5938b2603203 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?=
Date: Mon, 15 Jun 2020 13:25:55 +0300
Subject: [PATCH 12/13] Update API docs.
---
templates/docs/api.html | 67 +++++++++++++++++++++++++++++++++++++++++
templates/docs/api.md | 65 ++++++++++++++++++++-------------------
2 files changed, 99 insertions(+), 33 deletions(-)
diff --git a/templates/docs/api.html b/templates/docs/api.html
index 202bcf14..3c3d2554 100644
--- a/templates/docs/api.html
+++ b/templates/docs/api.html
@@ -43,6 +43,10 @@ checks in user's account.
GET SITE_ROOT/api/v1/checks/<uuid>/pings/ |
+Get a list of check's status changes |
+GET SITE_ROOT/api/v1/checks/<uuid>/flips/ |
+
+
Get a list of existing integrations |
GET SITE_ROOT/api/v1/channels/ |
@@ -679,6 +683,69 @@ number of returned pings depends on account's billing plan: 100 for free account
+Get a list of check's status changes
+GET SITE_ROOT/api/v1/checks/<uuid>/flips/
+GET SITE_ROOT/api/v1/checks/<unique_key>/flips/
+Returns a list of "flips" this check has experienced. A flip is a change of status
+(from "down" to "up", or from "up" to "down").
+Query String Parameters
+
+- seconds=<value>
+-
+
Returns the flips from the last value
seconds
+Example:
+SITE_ROOT/api/v1/checks/<uuid|unique_key>/flips/?seconds=3600
+
+- start=<value>
+-
+
Returns flips that are newer than the specified UNIX timestamp.
+Example:
+SITE_ROOT/api/v1/checks/<uuid|unique_key>/flips/?start=1592214380
+
+- end=<value>
+-
+
Returns flips that are older than the specified UNIX timestamp.
+Example:
+SITE_ROOT/api/v1/checks/<uuid|unique_key>/flips/?end=1592217980
+
+
+Response Codes
+
+- 200 OK
+- The request succeeded.
+- 400 Bad Request
+- Invalid query parameters.
+- 401 Unauthorized
+- The API key is either missing or invalid.
+- 403 Forbidden
+- Access denied, wrong API key.
+- 404 Not Found
+- The specified check does not exist.
+
+Example Request
+curl SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/flips/ \
+ --header "X-Api-Key: your-api-key"
+
+
+
+Example Response
+[
+ {
+ "timestamp": "2020-03-23T10:18:23+00:00",
+ "up": 1
+ },
+ {
+ "timestamp": "2020-03-23T10:17:15+00:00",
+ "up": 0
+ },
+ {
+ "timestamp": "2020-03-23T10:16:18+00:00",
+ "up": 1
+ }
+]
+
+
+
Get a List of Existing Integrations
GET SITE_ROOT/api/v1/channels/
Returns a list of integrations belonging to the project.
diff --git a/templates/docs/api.md b/templates/docs/api.md
index 8b2ab1e4..631c8bec 100644
--- a/templates/docs/api.md
+++ b/templates/docs/api.md
@@ -14,7 +14,8 @@ Endpoint Name | Endpoint Address
[Update an existing check](#update-check) | `POST SITE_ROOT/api/v1/checks/`
[Pause monitoring of a check](#pause-check) | `POST SITE_ROOT/api/v1/checks//pause`
[Delete check](#delete-check) | `DELETE SITE_ROOT/api/v1/checks/`
-[Get a list of check's logged pings](#list-pings) | `GET SITE_ROOT/api/v1/checks//pings/`
+[Get a list of check's logged pings](#list-pings) | `GET SITE_ROOT/api/v1/checks//pings/`
+[Get a list of check's status changes](#list-flips) | `GET SITE_ROOT/api/v1/checks//flips/`
[Get a list of existing integrations](#list-channels) | `GET SITE_ROOT/api/v1/channels/`
## Authentication
@@ -746,33 +747,36 @@ curl SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/pings/ \
```
-## Get a list of check's flips {: #list-flips .rule }
+## Get a list of check's status changes {: #list-flips .rule }
-`GET SITE_ROOT/api/v1/checks//flips/` or `GET SITE_ROOT/api/v1/checks//flips/`
+`GET SITE_ROOT/api/v1/checks//flips/`
+`GET SITE_ROOT/api/v1/checks//flips/`
-Returns a list of flips this check has experienced. A flip is a change of status (up, or down).
-
-This endpoint returns the status of a check for the period of time passed according to the below parameters. If no parameters are passed, the default is to return flips occuring in the previous 3600 seconds (`/?seconds=3600`), which is the last hour.
+Returns a list of "flips" this check has experienced. A flip is a change of status
+(from "down" to "up", or from "up" to "down").
### Query String Parameters
-Either the seconds or the start (and end) parameters can be passed. Passing both the seconds parameter and the start/end parameters will return a 400 error (see below).
-
seconds=<value>
-: Filters the checks, and returns the flip history in the last `n` seconds
+: Returns the flips from the last `value` seconds
Example:
`SITE_ROOT/api/v1/checks//flips/?seconds=3600`
-start=<value>&end=<value>
-: Filters the checks, and returns the flip history between the start and end timestamps.
+start=<value>
+: Returns flips that are newer than the specified UNIX timestamp.
+
+ Example:
- If provided, both values must be unix timestamps. The `end` parameter is optional and defaults to the timestamp of the current date and time.
+ `SITE_ROOT/api/v1/checks//flips/?start=1592214380`
+
+end=<value>
+: Returns flips that are older than the specified UNIX timestamp.
Example:
- `SITE_ROOT/api/v1/checks//flips/?seconds=3600`
+ `SITE_ROOT/api/v1/checks//flips/?end=1592217980`
### Response Codes
@@ -781,7 +785,7 @@ start=<value>&end=<value>
: The request succeeded.
400 Bad Request
-: Both a seconds and a start or end query string parameter has been passed, which is unsupported.
+: Invalid query parameters.
401 Unauthorized
: The API key is either missing or invalid.
@@ -802,25 +806,20 @@ curl SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/flips/ \
### Example Response
```json
-{
- "name": "My First Check",
- "tags": "",
- "desc": "",
- "grace": 3600,
- "n_pings": 2,
- "status": "up",
- "last_ping": "2020-06-12T02:18:46+00:00",
- "next_ping": "2020-06-13T02:18:46+00:00",
- "manual_resume": false,
- "history": [
- {
- "timestamp": "2020-03-23T23:30:18.767Z",
- "up": 1
- }
- ],
- "unique_key": "e855898bebff1756cde7c571319d877d07a38dab",
- "timeout": 86400
-}
+[
+ {
+ "timestamp": "2020-03-23T10:18:23+00:00",
+ "up": 1
+ },
+ {
+ "timestamp": "2020-03-23T10:17:15+00:00",
+ "up": 0
+ },
+ {
+ "timestamp": "2020-03-23T10:16:18+00:00",
+ "up": 1
+ }
+]
```
## Get a List of Existing Integrations {: #list-channels .rule }
From 5ab09f61f72116cbbf3644c57ad5f7eec7a0f36c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?=
Date: Mon, 15 Jun 2020 13:26:56 +0300
Subject: [PATCH 13/13] Update changelog
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5932928d..139a88aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
- Paused ping handling can be controlled via API (#376)
- Add "Get a list of checks's logged pings" API call (#371)
- The /api/v1/checks/ endpoint now accepts either UUID or `unique_key` (#370)
+- Added /api/v1/checks/uuid/flips/ endpoint (#349)
### Bug Fixes