From 1322bb11238fcec8e896878a3cd0d4f1bac5c4a8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?= <cuu508@gmail.com>
Date: Tue, 27 Feb 2024 12:55:51 +0200
Subject: [PATCH] Add support for per-check status badges

Fixes: #853
---
 CHANGELOG.md                              |  1 +
 hc/api/migrations/0103_check_badge_key.py | 18 +++++
 hc/api/models.py                          |  7 ++
 hc/api/tests/test_check_badge.py          | 88 +++++++++++++++++++++++
 hc/api/urls.py                            |  5 ++
 hc/api/views.py                           | 63 ++++++++++++----
 hc/front/forms.py                         |  3 +-
 hc/front/views.py                         | 23 ++++--
 static/css/badges.css                     |  3 +-
 templates/front/badges.html               | 17 +++++
 10 files changed, 207 insertions(+), 21 deletions(-)
 create mode 100644 hc/api/migrations/0103_check_badge_key.py
 create mode 100644 hc/api/tests/test_check_badge.py

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f31725ca..8a5be213 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
 - Update the WhatsApp integration to use Twilio Content Templates
 - Add auto-refresh functionality to the Log page (#957, @mickBoat00)
 - Redesign the "Status Badges" page
+- Add support for per-check status badges (#853)
 
 ### Bug Fixes
 - Fix Gotify integration to handle Gotify server URLs with paths (#964)
diff --git a/hc/api/migrations/0103_check_badge_key.py b/hc/api/migrations/0103_check_badge_key.py
new file mode 100644
index 00000000..6e16ed02
--- /dev/null
+++ b/hc/api/migrations/0103_check_badge_key.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.2 on 2024-02-27 08:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("api", "0102_alter_check_kind"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="check",
+            name="badge_key",
+            field=models.UUIDField(null=True, unique=True),
+        ),
+    ]
diff --git a/hc/api/models.py b/hc/api/models.py
index 57594e9d..15146dff 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -192,6 +192,7 @@ class Check(models.Model):
     failure_kw = models.CharField(max_length=200, blank=True)
     methods = models.CharField(max_length=30, blank=True)
     manual_resume = models.BooleanField(default=False)
+    badge_key = models.UUIDField(null=True, unique=True)
 
     n_pings = models.IntegerField(default=0)
     last_ping = models.DateTimeField(null=True, blank=True)
@@ -373,6 +374,12 @@ class Check(models.Model):
         code_half = self.code.hex[:16]
         return hashlib.sha1(code_half.encode()).hexdigest()
 
+    def prepare_badge_key(self) -> uuid.UUID:
+        if not self.badge_key:
+            self.badge_key = uuid.uuid4()
+            Check.objects.filter(id=self.id).update(badge_key=self.badge_key)
+        return self.badge_key
+
     def to_dict(self, *, readonly: bool = False, v: int = 3) -> CheckDict:
         with_started = v == 1
         result: CheckDict = {
diff --git a/hc/api/tests/test_check_badge.py b/hc/api/tests/test_check_badge.py
new file mode 100644
index 00000000..5457ee4e
--- /dev/null
+++ b/hc/api/tests/test_check_badge.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from datetime import timedelta as td
+
+from django.utils.timezone import now
+
+from hc.api.models import Check
+from hc.test import BaseTestCase
+
+
+class CheckBadgeTestCase(BaseTestCase):
+    def setUp(self) -> None:
+        super().setUp()
+        self.check = Check.objects.create(project=self.project, name="foobar")
+        badge_key = self.check.prepare_badge_key()
+
+        self.svg_url = f"/b/2/{badge_key}.svg"
+        self.json_url = f"/b/2/{badge_key}.json"
+        self.with_late_url = f"/b/3/{badge_key}.json"
+        self.shields_url = f"/b/2/{badge_key}.shields"
+
+    def test_it_handles_bad_badge_key(self) -> None:
+        r = self.client.get("/b/2/869fe06a-a604-4140-b15a-118637c25f3e.svg")
+        self.assertEqual(r.status_code, 404)
+
+    def test_it_returns_svg(self) -> None:
+        r = self.client.get(self.svg_url)
+        self.assertEqual(r["Access-Control-Allow-Origin"], "*")
+        self.assertIn("no-cache", r["Cache-Control"])
+        self.assertContains(r, "#4c1")
+
+    def test_it_rejects_bad_format(self) -> None:
+        r = self.client.get(self.json_url + "foo")
+        self.assertEqual(r.status_code, 404)
+
+    def test_it_handles_options(self) -> None:
+        r = self.client.options(self.svg_url)
+        self.assertEqual(r.status_code, 204)
+        self.assertEqual(r["Access-Control-Allow-Origin"], "*")
+
+    def test_it_handles_new(self) -> None:
+        doc = self.client.get(self.json_url).json()
+        self.assertEqual(doc, {"status": "up", "total": 1, "grace": 0, "down": 0})
+
+    def test_it_ignores_started_when_down(self) -> None:
+        self.check.last_start = now()
+        self.check.status = "down"
+        self.check.save()
+
+        doc = self.client.get(self.json_url).json()
+        self.assertEqual(doc, {"status": "down", "total": 1, "grace": 0, "down": 1})
+
+    def test_it_treats_late_as_up(self) -> None:
+        self.check.last_ping = now() - td(days=1, minutes=10)
+        self.check.status = "up"
+        self.check.save()
+
+        doc = self.client.get(self.json_url).json()
+        self.assertEqual(doc, {"status": "up", "total": 1, "grace": 1, "down": 0})
+
+    def test_late_mode_returns_late_status(self) -> None:
+        self.check.last_ping = now() - td(days=1, minutes=10)
+        self.check.status = "up"
+        self.check.save()
+
+        doc = self.client.get(self.with_late_url).json()
+        self.assertEqual(doc, {"status": "late", "total": 1, "grace": 1, "down": 0})
+
+    def test_late_mode_ignores_started_when_late(self) -> None:
+        self.check.last_start = now()
+        self.check.last_ping = now() - td(days=1, minutes=10)
+        self.check.status = "up"
+        self.check.save()
+
+        doc = self.client.get(self.with_late_url).json()
+        self.assertEqual(doc, {"status": "late", "total": 1, "grace": 1, "down": 0})
+
+    def test_it_returns_shields_json(self) -> None:
+        doc = self.client.get(self.shields_url).json()
+        self.assertEqual(
+            doc,
+            {
+                "schemaVersion": 1,
+                "label": "foobar",
+                "message": "up",
+                "color": "success",
+            },
+        )
diff --git a/hc/api/urls.py b/hc/api/urls.py
index d79f7697..32175248 100644
--- a/hc/api/urls.py
+++ b/hc/api/urls.py
@@ -90,4 +90,9 @@ urlpatterns = [
         {"tag": "*"},
         name="hc-badge-all",
     ),
+    path(
+        "b/<int:states>/<uuid:badge_key>.<slug:fmt>",
+        views.check_badge,
+        name="hc-badge-check",
+    ),
 ]
diff --git a/hc/api/views.py b/hc/api/views.py
index 67fd9e83..8ccb230a 100644
--- a/hc/api/views.py
+++ b/hc/api/views.py
@@ -685,6 +685,20 @@ def badges(request: ApiRequest) -> JsonResponse:
     return JsonResponse({"badges": badges})
 
 
+SHIELDS_COLORS = {"up": "success", "late": "important", "down": "critical"}
+
+
+def _shields_response(label: str, status: str):
+    return JsonResponse(
+        {
+            "schemaVersion": 1,
+            "label": label,
+            "message": status,
+            "color": SHIELDS_COLORS[status],
+        }
+    )
+
+
 @never_cache
 @cors("GET")
 def badge(
@@ -701,11 +715,11 @@ def badge(
         return HttpResponseNotFound()
 
     q = Check.objects.filter(project__badge_key=badge_key)
-    if tag != "*":
+    if tag == "*":
+        label = settings.MASTER_BADGE_LABEL
+    else:
         q = q.filter(tags__contains=tag)
         label = tag
-    else:
-        label = settings.MASTER_BADGE_LABEL
 
     status, total, grace, down = "up", 0, 0, 0
     for check in q:
@@ -728,15 +742,7 @@ def badge(
                 status = "late"
 
     if fmt == "shields":
-        color = "success"
-        if status == "down":
-            color = "critical"
-        elif status == "late":
-            color = "important"
-
-        return JsonResponse(
-            {"schemaVersion": 1, "label": label, "message": status, "color": color}
-        )
+        return _shields_response(label, status)
 
     if fmt == "json":
         return JsonResponse(
@@ -747,6 +753,39 @@ def badge(
     return HttpResponse(svg, content_type="image/svg+xml")
 
 
+@never_cache
+@cors("GET")
+def check_badge(
+    request: HttpRequest, states: int, badge_key: UUID, fmt: str
+) -> HttpResponse:
+    if fmt not in ("svg", "json", "shields"):
+        return HttpResponseNotFound()
+
+    check = get_object_or_404(Check, badge_key=badge_key)
+    check_status = check.get_status()
+    status = "up"
+    if check_status == "down":
+        status = "down"
+    elif check_status == "grace" and states == 3:
+        status = "late"
+
+    if fmt == "shields":
+        return _shields_response(check.name_then_code(), status)
+
+    if fmt == "json":
+        return JsonResponse(
+            {
+                "status": status,
+                "total": 1,
+                "grace": 1 if check_status == "grace" else 0,
+                "down": 1 if check_status == "down" else 0,
+            }
+        )
+
+    svg = get_badge_svg(check.name_then_code(), status)
+    return HttpResponse(svg, content_type="image/svg+xml")
+
+
 @csrf_exempt
 @require_POST
 def notification_status(request: HttpRequest, code: UUID) -> HttpResponse:
diff --git a/hc/front/forms.py b/hc/front/forms.py
index 4edf1a2a..dfe542f7 100644
--- a/hc/front/forms.py
+++ b/hc/front/forms.py
@@ -406,7 +406,8 @@ class AddTelegramForm(forms.Form):
 
 
 class BadgeSettingsForm(forms.Form):
-    target = forms.ChoiceField(choices=_choices("all,tag"))
+    target = forms.ChoiceField(choices=_choices("all,tag,check"))
     tag = forms.CharField(max_length=100, required=False)
+    check = forms.UUIDField(required=False)
     fmt = forms.ChoiceField(choices=_choices("svg,json,shields"))
     states = forms.ChoiceField(choices=_choices("2,3"))
diff --git a/hc/front/views.py b/hc/front/views.py
index cbf013f7..73a078e0 100644
--- a/hc/front/views.py
+++ b/hc/front/views.py
@@ -1101,28 +1101,37 @@ def badges(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
         if not form.is_valid():
             return HttpResponseBadRequest()
 
+        fmt = form.cleaned_data["fmt"]
+        states = form.cleaned_data["states"]
+        with_late = True if states == "3" else False
         if form.cleaned_data["target"] == "all":
             label = settings.MASTER_BADGE_LABEL
-            tag = "*"
-        else:
+            url = get_badge_url(project.badge_key, "*", fmt, with_late)
+        elif form.cleaned_data["target"] == "tag":
             label = form.cleaned_data["tag"]
-            tag = form.cleaned_data["tag"]
-        fmt = form.cleaned_data["fmt"]
-        with_late = True if form.cleaned_data["states"] == "3" else False
-        url = get_badge_url(project.badge_key, tag, fmt, with_late)
+            url = get_badge_url(project.badge_key, label, fmt, with_late)
+        elif form.cleaned_data["target"] == "check":
+            check = project.check_set.get(code=form.cleaned_data["check"])
+            url = settings.SITE_ROOT + reverse(
+                "hc-badge-check", args=[states, check.prepare_badge_key(), fmt]
+            )
+            label = check.name_then_code()
+
         if fmt == "shields":
             url = "https://img.shields.io/endpoint?" + urlencode({"url": url})
 
         ctx = {"fmt": fmt, "label": label, "url": url}
         return render(request, "front/badges_preview.html", ctx)
 
+    checks = list(Check.objects.filter(project=project).order_by("name"))
     tags = set()
-    for check in Check.objects.filter(project=project):
+    for check in checks:
         tags.update(check.tags_list())
 
     sorted_tags = sorted(tags, key=lambda s: s.lower())
 
     ctx = {
+        "checks": checks,
         "tags": sorted_tags,
         "fmt": "svg",
         "label": settings.MASTER_BADGE_LABEL,
diff --git a/static/css/badges.css b/static/css/badges.css
index 0f20d43b..b9fb121c 100644
--- a/static/css/badges.css
+++ b/static/css/badges.css
@@ -1,4 +1,5 @@
-#status-badges #tag {
+#status-badges #tag,
+#status-badges #check {
     margin-left: 49px;
     width: 200px
 }
diff --git a/templates/front/badges.html b/templates/front/badges.html
index ba0f1fb1..5ed8dd05 100644
--- a/templates/front/badges.html
+++ b/templates/front/badges.html
@@ -54,6 +54,23 @@
                 {% endfor %}
             </select>
             {% endif %}
+            {% if checks %}
+            <label class="radio-container">
+                <input type="radio" name="target" value="check" autocomplete="off">
+                <span class="radiomark"></span>
+                A specific check:
+            </label>
+
+            <select
+                id="check"
+                name="check"
+                class="form-control"
+                autocomplete="off">
+                {% for check in checks %}
+                <option value="{{ check.code }}">{{ check.name_then_code }}</option>
+                {% endfor %}
+            </select>
+            {% endif %}
         </div>
         <div class="form-group">
         <p>Badge format</p>