diff --git a/hc/accounts/migrations/0045_auto_20210908_1257.py b/hc/accounts/migrations/0045_auto_20210908_1257.py new file mode 100644 index 00000000..b2925640 --- /dev/null +++ b/hc/accounts/migrations/0045_auto_20210908_1257.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.6 on 2021-09-08 12:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0044_auto_20210730_0942'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='ping_key', + field=models.CharField(blank=True, max_length=128, null=True, unique=True), + ), + migrations.AddField( + model_name='project', + name='show_slugs', + field=models.BooleanField(default=False), + ), + ] diff --git a/hc/accounts/models.py b/hc/accounts/models.py index 88268aed..a615db8e 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -322,6 +322,8 @@ class Project(models.Model): api_key = models.CharField(max_length=128, blank=True, db_index=True) api_key_readonly = models.CharField(max_length=128, blank=True, db_index=True) badge_key = models.CharField(max_length=150, unique=True) + ping_key = models.CharField(max_length=128, blank=True, null=True, unique=True) + show_slugs = models.BooleanField(default=False) def __str__(self): return self.name or self.owner.email @@ -337,8 +339,15 @@ class Project(models.Model): return self.owner_profile.num_checks_available() def set_api_keys(self): - self.api_key = token_urlsafe(nbytes=24) - self.api_key_readonly = token_urlsafe(nbytes=24) + def pick(nbytes=24): + while True: + candidate = token_urlsafe(nbytes) + if candidate[0] not in "-_" and candidate[-1] not in "-_": + return candidate + + self.api_key = pick() + self.api_key_readonly = pick() + self.ping_key = pick(16) self.save() def invite_suggestions(self): diff --git a/hc/accounts/tests/test_signup.py b/hc/accounts/tests/test_signup.py index 89e97459..d55ca632 100644 --- a/hc/accounts/tests/test_signup.py +++ b/hc/accounts/tests/test_signup.py @@ -39,6 +39,7 @@ class SignupTestCase(TestCase): # And check should be associated with the new user check = Check.objects.get() self.assertEqual(check.name, "My First Check") + self.assertEqual(check.slug, "my-first-check") self.assertEqual(check.project, project) # A channel should have been created diff --git a/hc/accounts/views.py b/hc/accounts/views.py index f5fab7cd..c094f7b9 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -79,7 +79,7 @@ def _make_user(email, tz=None, with_project=True): project.save() check = Check(project=project) - check.name = "My First Check" + check.set_name_slug("My First Check") check.save() channel = Channel(project=project) @@ -336,6 +336,7 @@ def project(request, code): project.api_key = "" project.api_key_readonly = "" + project.ping_key = None project.save() ctx["api_keys_revoked"] = True diff --git a/hc/api/migrations/0079_auto_20210907_0918.py b/hc/api/migrations/0079_auto_20210907_0918.py new file mode 100644 index 00000000..3a7cf93a --- /dev/null +++ b/hc/api/migrations/0079_auto_20210907_0918.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.6 on 2021-09-07 09:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0078_sms_values'), + ] + + operations = [ + migrations.AddField( + model_name='check', + name='slug', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddIndex( + model_name='check', + index=models.Index(fields=['project_id', 'slug'], name='api_check_project_slug'), + ), + ] diff --git a/hc/api/migrations/0080_fill_slug.py b/hc/api/migrations/0080_fill_slug.py new file mode 100644 index 00000000..1ce0e0ee --- /dev/null +++ b/hc/api/migrations/0080_fill_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.6 on 2021-09-07 09:19 + +from django.db import migrations +from django.utils.text import slugify + + +def fill_slug(apps, schema_editor): + Check = apps.get_model("api", "CHeck") + for c in Check.objects.exclude(name="").only("name"): + Check.objects.filter(id=c.id).update(slug=slugify(c.name)) + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0079_auto_20210907_0918"), + ] + + operations = [migrations.RunPython(fill_slug, migrations.RunPython.noop)] diff --git a/hc/api/models.py b/hc/api/models.py index 243264a2..b27f352d 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -12,6 +12,7 @@ from django.core.signing import TimestampSigner from django.db import models from django.urls import reverse from django.utils import timezone +from django.utils.text import slugify from hc.accounts.models import Project from hc.api import transports from hc.lib import emails @@ -68,6 +69,7 @@ def isostring(dt): class Check(models.Model): name = models.CharField(max_length=100, blank=True) + slug = models.CharField(max_length=100, blank=True) tags = models.CharField(max_length=500, blank=True) code = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) desc = models.TextField(blank=True) @@ -100,7 +102,8 @@ class Check(models.Model): fields=["alert_after"], name="api_check_aa_not_down", condition=~models.Q(status="down"), - ) + ), + models.Index(fields=["project_id", "slug"], name="api_check_project_slug"), ] def __str__(self): @@ -112,8 +115,23 @@ class Check(models.Model): return str(self.code) + def relative_url(self): + if self.project.show_slugs: + if not self.slug: + return None + + key = self.project.ping_key + # If ping_key is not set, use dummy placeholder + if key is None: + key = "{ping_key}" + return key + "/" + self.slug + + return str(self.code) + def url(self): - return settings.PING_ENDPOINT + str(self.code) + s = self.relative_url() + if s: + return settings.PING_ENDPOINT + s def details_url(self): return settings.SITE_ROOT + reverse("hc-details", args=[self.code]) @@ -128,6 +146,10 @@ class Check(models.Model): if self.last_duration and self.last_duration < MAX_DELTA: return self.last_duration + def set_name_slug(self, name): + self.name = name + self.slug = slugify(name) + def get_grace_start(self, with_started=True): """ Return the datetime when the grace period starts. @@ -224,6 +246,7 @@ class Check(models.Model): result = { "name": self.name, + "slug": self.slug, "tags": self.tags, "desc": self.desc, "grace": int(self.grace.total_seconds()), diff --git a/hc/api/tests/test_create_check.py b/hc/api/tests/test_create_check.py index d1375f6d..2326c51a 100644 --- a/hc/api/tests/test_create_check.py +++ b/hc/api/tests/test_create_check.py @@ -28,6 +28,7 @@ class CreateCheckTestCase(BaseTestCase): doc = r.json() assert "ping_url" in doc self.assertEqual(doc["name"], "Foo") + self.assertEqual(doc["slug"], "foo") self.assertEqual(doc["tags"], "bar,baz") self.assertEqual(doc["last_ping"], None) self.assertEqual(doc["n_pings"], 0) @@ -38,6 +39,7 @@ class CreateCheckTestCase(BaseTestCase): check = Check.objects.get() self.assertEqual(check.name, "Foo") + self.assertEqual(check.slug, "foo") self.assertEqual(check.tags, "bar,baz") self.assertEqual(check.methods, "") self.assertEqual(check.timeout.total_seconds(), 3600) diff --git a/hc/api/tests/test_get_check.py b/hc/api/tests/test_get_check.py index facd1792..dd2b78b5 100644 --- a/hc/api/tests/test_get_check.py +++ b/hc/api/tests/test_get_check.py @@ -11,7 +11,8 @@ class GetCheckTestCase(BaseTestCase): self.now = now().replace(microsecond=0) - self.a1 = Check(project=self.project, name="Alice 1") + self.a1 = Check(project=self.project) + self.a1.set_name_slug("Alice 1") self.a1.timeout = td(seconds=3600) self.a1.grace = td(seconds=900) self.a1.n_pings = 0 @@ -33,8 +34,9 @@ class GetCheckTestCase(BaseTestCase): self.assertEqual(r["Access-Control-Allow-Origin"], "*") doc = r.json() - self.assertEqual(len(doc), 15) + self.assertEqual(len(doc), 16) + self.assertEqual(doc["slug"], "alice-1") self.assertEqual(doc["timeout"], 3600) self.assertEqual(doc["grace"], 900) self.assertEqual(doc["ping_url"], self.a1.url()) @@ -61,7 +63,7 @@ class GetCheckTestCase(BaseTestCase): self.assertEqual(r["Access-Control-Allow-Origin"], "*") doc = r.json() - self.assertEqual(len(doc), 15) + self.assertEqual(len(doc), 16) self.assertEqual(doc["timeout"], 3600) self.assertEqual(doc["grace"], 900) diff --git a/hc/api/tests/test_update_check.py b/hc/api/tests/test_update_check.py index 8c7f324a..a42f4c5d 100644 --- a/hc/api/tests/test_update_check.py +++ b/hc/api/tests/test_update_check.py @@ -38,6 +38,7 @@ class UpdateCheckTestCase(BaseTestCase): doc = r.json() assert "ping_url" in doc self.assertEqual(doc["name"], "Foo") + self.assertEqual(doc["slug"], "foo") self.assertEqual(doc["tags"], "bar,baz") self.assertEqual(doc["desc"], "My description") self.assertEqual(doc["n_pings"], 0) diff --git a/hc/api/urls.py b/hc/api/urls.py index 8aeeb222..833fa2c4 100644 --- a/hc/api/urls.py +++ b/hc/api/urls.py @@ -33,6 +33,7 @@ urlpatterns = [ path("ping//fail", views.ping, {"action": "fail"}, name="hc-fail"), path("ping//start", views.ping, {"action": "start"}, name="hc-start"), path("ping//", views.ping), + path("ping//", views.ping_by_slug), path("api/v1/checks/", views.checks), path("api/v1/checks/", views.single, name="hc-api-single"), path("api/v1/checks/", views.get_check_by_unique_key), diff --git a/hc/api/views.py b/hc/api/views.py index 9f40779a..97aee228 100644 --- a/hc/api/views.py +++ b/hc/api/views.py @@ -55,6 +55,11 @@ def ping(request, code, action="success", exitstatus=None): return response +def ping_by_slug(request, ping_key, slug, action="success", exitstatus=None): + check = get_object_or_404(Check, slug=slug, project__ping_key=ping_key) + return ping(request, check.code, action, exitstatus) + + def _lookup(project, spec): unique_fields = spec.get("unique", []) if unique_fields: @@ -108,7 +113,7 @@ def _update(check, spec): need_save = True if "name" in spec and check.name != spec["name"]: - check.name = spec["name"] + check.set_name_slug(spec["name"]) need_save = True if "tags" in spec and check.tags != spec["tags"]: diff --git a/hc/front/tests/test_copy.py b/hc/front/tests/test_copy.py index ff98b7b7..75f84eff 100644 --- a/hc/front/tests/test_copy.py +++ b/hc/front/tests/test_copy.py @@ -7,6 +7,7 @@ class CopyCheckTestCase(BaseTestCase): super().setUp() self.check = Check(project=self.project) self.check.name = "Foo" + self.check.slug = "foo" self.check.subject = "success-keyword" self.check.subject_fail = "failure-keyword" self.check.methods = "POST" @@ -21,6 +22,7 @@ class CopyCheckTestCase(BaseTestCase): self.assertContains(r, "This is a brand new check") copy = Check.objects.get(name="Foo (copy)") + self.assertEqual(copy.slug, "foo-copy") self.assertEqual(copy.subject, "success-keyword") self.assertEqual(copy.subject_fail, "failure-keyword") self.assertEqual(copy.methods, "POST") diff --git a/hc/front/tests/test_update_name.py b/hc/front/tests/test_update_name.py index a27283ca..c9028eca 100644 --- a/hc/front/tests/test_update_name.py +++ b/hc/front/tests/test_update_name.py @@ -17,6 +17,7 @@ class UpdateNameTestCase(BaseTestCase): self.check.refresh_from_db() self.assertEqual(self.check.name, "Alice Was Here") + self.assertEqual(self.check.slug, "alice-was-here") def test_team_access_works(self): payload = {"name": "Bob Was Here"} diff --git a/hc/front/views.py b/hc/front/views.py index e2ad126b..6314b145 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -174,6 +174,10 @@ def my_checks(request, code): request.profile.sort = request.GET["sort"] request.profile.save() + if request.GET.get("urls") in ("uuid", "slug") and rw: + project.show_slugs = request.GET["urls"] == "slug" + project.save() + if request.session.get("last_project_id") != project.id: request.session["last_project_id"] = project.id @@ -402,7 +406,7 @@ def update_name(request, code): form = forms.NameTagsForm(request.POST) if form.is_valid(): - check.name = form.cleaned_data["name"] + check.set_name_slug(form.cleaned_data["name"]) check.tags = form.cleaned_data["tags"] check.desc = form.cleaned_data["desc"] check.save() @@ -694,7 +698,7 @@ def copy(request, code): new_name = check.name[:90] + "... (copy)" copied = Check(project=check.project) - copied.name = new_name + copied.set_name_slug(new_name) copied.desc, copied.tags = check.desc, check.tags copied.subject, copied.subject_fail = check.subject, check.subject_fail copied.methods = check.methods diff --git a/static/css/my_checks_desktop.css b/static/css/my_checks_desktop.css index e572fbe6..6dcd9643 100644 --- a/static/css/my_checks_desktop.css +++ b/static/css/my_checks_desktop.css @@ -6,7 +6,7 @@ background-color: var(--table-bg-hover); } -.my-checks-name.unnamed { +.my-checks-name.unnamed, .url span.unavailable { color: var(--text-muted); font-style: italic; } @@ -132,7 +132,7 @@ tr:hover .copy-link { white-space: nowrap; } -#checks-table .btn { +#checks-table td .btn { border-color: transparent; background-color: transparent; font-size: 20px; @@ -159,3 +159,11 @@ tr:hover .copy-link { color: #FFF; outline: none; } + +#url-style-switcher a { + color: var(--text-muted); +} + +#url-style-switcher a.active { + text-decoration: underline; +} \ No newline at end of file diff --git a/templates/accounts/project.html b/templates/accounts/project.html index 324a253f..7ca8f6e7 100644 --- a/templates/accounts/project.html +++ b/templates/accounts/project.html @@ -89,9 +89,17 @@ API key (read-only):
{{ project.api_key_readonly }}

-

Related links:

+ {% endif %} + {% if project.ping_key %} +

+ Ping key:
+

{{ project.ping_key }}
+

+ {% endif %} +

See also:

- {% endif %} + {% endif %} {% if channels|length <= 10 %}