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