forked from GithubBackups/healthchecks
For HipChat integration, use HipChat's server-side installation flow.
This commit is contained in:
parent
de7160a0e6
commit
bef71c0acc
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta as td
|
from datetime import datetime, timedelta as td
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from hc.api import transports
|
from hc.api import transports
|
||||||
from hc.lib import emails
|
from hc.lib import emails
|
||||||
|
import requests
|
||||||
|
|
||||||
STATUSES = (
|
STATUSES = (
|
||||||
("up", "Up"),
|
("up", "Up"),
|
||||||
@ -377,6 +379,37 @@ class Channel(models.Model):
|
|||||||
doc = json.loads(self.value)
|
doc = json.loads(self.value)
|
||||||
return doc.get("name")
|
return doc.get("name")
|
||||||
|
|
||||||
|
def refresh_hipchat_access_token(self):
|
||||||
|
assert self.kind == "hipchat"
|
||||||
|
if not self.value.startswith("{"):
|
||||||
|
return # Don't have OAuth credentials
|
||||||
|
|
||||||
|
doc = json.loads(self.value)
|
||||||
|
if time.time() < doc.get("expires_at", 0):
|
||||||
|
return # Current access token is still valid
|
||||||
|
|
||||||
|
url = "https://api.hipchat.com/v2/oauth/token"
|
||||||
|
auth = (doc["oauthId"], doc["oauthSecret"])
|
||||||
|
r = requests.post(url, auth=auth, data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": "send_notification"
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.update(r.json())
|
||||||
|
doc["expires_at"] = int(time.time()) + doc["expires_in"] - 300
|
||||||
|
self.value = json.dumps(doc)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hipchat_webhook_url(self):
|
||||||
|
assert self.kind == "hipchat"
|
||||||
|
if not self.value.startswith("{"):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
doc = json.loads(self.value)
|
||||||
|
tmpl = "https://api.hipchat.com/v2/room/%s/notification?auth_token=%s"
|
||||||
|
return tmpl % (doc["roomId"], doc.get("access_token"))
|
||||||
|
|
||||||
def latest_notification(self):
|
def latest_notification(self):
|
||||||
return Notification.objects.filter(channel=self).latest()
|
return Notification.objects.filter(channel=self).latest()
|
||||||
|
|
||||||
|
21
hc/api/tests/test_channel_model.py
Normal file
21
hc/api/tests/test_channel_model.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from hc.api.models import Channel
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelModelTestCase(BaseTestCase):
|
||||||
|
|
||||||
|
@patch("hc.api.models.requests.post")
|
||||||
|
def test_it_refreshes_hipchat_access_token(self, mock_post):
|
||||||
|
mock_post.return_value.json.return_value = {"expires_in": 100}
|
||||||
|
|
||||||
|
channel = Channel(kind="hipchat", user=self.alice, value=json.dumps({
|
||||||
|
"oauthId": "foo",
|
||||||
|
"oauthSecret": "bar"
|
||||||
|
}))
|
||||||
|
|
||||||
|
channel.refresh_hipchat_access_token()
|
||||||
|
self.assertTrue(mock_post.return_value.json.called)
|
||||||
|
self.assertTrue("expires_at" in channel.value)
|
@ -175,7 +175,9 @@ class HipChat(HttpTransport):
|
|||||||
"message": text,
|
"message": text,
|
||||||
"color": "green" if check.status == "up" else "red",
|
"color": "green" if check.status == "up" else "red",
|
||||||
}
|
}
|
||||||
return self.post(self.channel.value, json=payload)
|
|
||||||
|
self.channel.refresh_hipchat_access_token()
|
||||||
|
return self.post(self.channel.hipchat_webhook_url, json=payload)
|
||||||
|
|
||||||
|
|
||||||
class OpsGenie(HttpTransport):
|
class OpsGenie(HttpTransport):
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django.core import signing
|
||||||
from hc.api.models import Channel
|
from hc.api.models import Channel
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
|
|
||||||
class AddHipChatTestCase(BaseTestCase):
|
class AddHipChatTestCase(BaseTestCase):
|
||||||
@ -10,29 +14,38 @@ class AddHipChatTestCase(BaseTestCase):
|
|||||||
r = self.client.get(self.url)
|
r = self.client.get(self.url)
|
||||||
self.assertContains(r, "appropriate HipChat room")
|
self.assertContains(r, "appropriate HipChat room")
|
||||||
|
|
||||||
def test_it_works(self):
|
def test_instructions_work_when_logged_out(self):
|
||||||
form = {"value": "http://example.org"}
|
r = self.client.get(self.url)
|
||||||
|
self.assertContains(r, "Before adding HipChat integration, please")
|
||||||
|
|
||||||
|
def test_it_redirects_to_addons_install(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
r = self.client.post(self.url, form)
|
r = self.client.post(self.url)
|
||||||
self.assertRedirects(r, "/integrations/")
|
self.assertEqual(r.status_code, 302)
|
||||||
|
|
||||||
|
def test_it_returns_capabilities(self):
|
||||||
|
r = self.client.get("/integrations/hipchat/capabilities/")
|
||||||
|
self.assertContains(r, "callbackUrl")
|
||||||
|
|
||||||
|
@patch("hc.api.models.Channel.refresh_hipchat_access_token")
|
||||||
|
def test_callback_works(self, mock_refresh):
|
||||||
|
state = signing.TimestampSigner().sign("alice")
|
||||||
|
payload = json.dumps({"relayState": state, "foo": "foobar"})
|
||||||
|
|
||||||
|
r = self.client.post("/integrations/hipchat/callback/", payload,
|
||||||
|
content_type="application/json")
|
||||||
|
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
c = Channel.objects.get()
|
c = Channel.objects.get()
|
||||||
self.assertEqual(c.kind, "hipchat")
|
self.assertEqual(c.kind, "hipchat")
|
||||||
self.assertEqual(c.value, "http://example.org")
|
self.assertTrue("foobar" in c.value)
|
||||||
|
|
||||||
def test_it_rejects_bad_url(self):
|
@patch("hc.api.models.Channel.refresh_hipchat_access_token")
|
||||||
form = {"value": "not an URL"}
|
def test_callback_rejects_bad_signature(self, mock_refresh):
|
||||||
|
payload = json.dumps({"relayState": "alice:bad:sig", "foo": "foobar"})
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
r = self.client.post("/integrations/hipchat/callback/", payload,
|
||||||
r = self.client.post(self.url, form)
|
content_type="application/json")
|
||||||
self.assertContains(r, "Enter a valid URL")
|
|
||||||
|
|
||||||
def test_it_trims_whitespace(self):
|
self.assertEqual(r.status_code, 400)
|
||||||
form = {"value": " http://example.org "}
|
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
self.client.post(self.url, form)
|
|
||||||
|
|
||||||
c = Channel.objects.get()
|
|
||||||
self.assertEqual(c.value, "http://example.org")
|
|
||||||
|
@ -19,6 +19,8 @@ channel_urls = [
|
|||||||
url(r'^add_slack/$', views.add_slack, name="hc-add-slack"),
|
url(r'^add_slack/$', views.add_slack, name="hc-add-slack"),
|
||||||
url(r'^add_slack_btn/$', views.add_slack_btn, name="hc-add-slack-btn"),
|
url(r'^add_slack_btn/$', views.add_slack_btn, name="hc-add-slack-btn"),
|
||||||
url(r'^add_hipchat/$', views.add_hipchat, name="hc-add-hipchat"),
|
url(r'^add_hipchat/$', views.add_hipchat, name="hc-add-hipchat"),
|
||||||
|
url(r'^hipchat/capabilities/$', views.hipchat_capabilities, name="hc-hipchat-capabilities"),
|
||||||
|
url(r'^hipchat/callback/$', views.hipchat_callback, name="hc-hipchat-callback"),
|
||||||
url(r'^add_pushbullet/$', views.add_pushbullet, name="hc-add-pushbullet"),
|
url(r'^add_pushbullet/$', views.add_pushbullet, name="hc-add-pushbullet"),
|
||||||
url(r'^add_discord/$', views.add_discord, name="hc-add-discord"),
|
url(r'^add_discord/$', views.add_discord, name="hc-add-discord"),
|
||||||
url(r'^add_pushover/$', views.add_pushover, name="hc-add-pushover"),
|
url(r'^add_pushover/$', views.add_pushover, name="hc-add-pushover"),
|
||||||
|
@ -6,11 +6,12 @@ import json
|
|||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.http import (Http404, HttpResponse, HttpResponseBadRequest,
|
from django.http import (Http404, HttpResponse, HttpResponseBadRequest,
|
||||||
HttpResponseForbidden)
|
HttpResponseForbidden, JsonResponse)
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -552,22 +553,67 @@ def add_slack_btn(request):
|
|||||||
return redirect("hc-channels")
|
return redirect("hc-channels")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def add_hipchat(request):
|
def add_hipchat(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = AddUrlForm(request.POST)
|
username = request.team.user.username
|
||||||
if form.is_valid():
|
state = signing.TimestampSigner().sign(username)
|
||||||
channel = Channel(user=request.team.user, kind="hipchat")
|
capabilities = settings.SITE_ROOT + reverse("hc-hipchat-capabilities")
|
||||||
channel.value = form.cleaned_data["value"]
|
|
||||||
|
url = "https://www.hipchat.com/addons/install?url=%s&relayState=%s" % \
|
||||||
|
(capabilities, state)
|
||||||
|
|
||||||
|
return redirect(url)
|
||||||
|
|
||||||
|
ctx = {"page": "channels"}
|
||||||
|
return render(request, "integrations/add_hipchat.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def hipchat_capabilities(request):
|
||||||
|
return JsonResponse({
|
||||||
|
"name": settings.SITE_NAME,
|
||||||
|
"description": "Get Notified When Your Cron Jobs Fail",
|
||||||
|
"key": "io.healthchecks.hipchat",
|
||||||
|
"links": {
|
||||||
|
"homepage": settings.SITE_ROOT,
|
||||||
|
"self": settings.SITE_ROOT + reverse("hc-hipchat-capabilities")
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"installable": {
|
||||||
|
"allowGlobal": False,
|
||||||
|
"allowRoom": True,
|
||||||
|
"callbackUrl":
|
||||||
|
settings.SITE_ROOT + reverse("hc-hipchat-callback"),
|
||||||
|
"installedUrl":
|
||||||
|
settings.SITE_ROOT + reverse("hc-channels") + "?added=hipchat"
|
||||||
|
},
|
||||||
|
"hipchatApiConsumer": {
|
||||||
|
"scopes": [
|
||||||
|
"send_notification"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_POST
|
||||||
|
def hipchat_callback(request):
|
||||||
|
doc = json.loads(request.body.decode("utf-8"))
|
||||||
|
try:
|
||||||
|
signer = signing.TimestampSigner()
|
||||||
|
username = signer.unsign(doc.get("relayState"), max_age=300)
|
||||||
|
except signing.BadSignature:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
channel = Channel(kind="hipchat")
|
||||||
|
channel.user = User.objects.get(username=username)
|
||||||
|
channel.value = json.dumps(doc)
|
||||||
channel.save()
|
channel.save()
|
||||||
|
|
||||||
|
channel.refresh_hipchat_access_token()
|
||||||
channel.assign_all_checks()
|
channel.assign_all_checks()
|
||||||
return redirect("hc-channels")
|
|
||||||
else:
|
|
||||||
form = AddUrlForm()
|
|
||||||
|
|
||||||
ctx = {"page": "channels", "form": form}
|
return HttpResponse()
|
||||||
return render(request, "integrations/add_hipchat.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
.field-value {
|
.results .field-value {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
.channels-table .channel-row > td {
|
.channels-table .channel-row > td {
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels-table .value-cell {
|
.channels-table .value-cell {
|
||||||
@ -166,7 +167,6 @@ table.channels-table > tbody > tr > th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-guide-screenshot {
|
.ai-guide-screenshot {
|
||||||
border: ;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border: 6px solid #EEE;
|
border: 6px solid #EEE;
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
Binary file not shown.
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 20 KiB |
Binary file not shown.
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 32 KiB |
@ -89,6 +89,8 @@
|
|||||||
<span class="preposition">user</span>
|
<span class="preposition">user</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ ch.telegram_name }}
|
{{ ch.telegram_name }}
|
||||||
|
{% elif ch.kind == "hipchat" %}
|
||||||
|
{{ ch.hipchat_webhook_url }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ch.value }}
|
{{ ch.value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -9,17 +9,64 @@
|
|||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<h1>HipChat</h1>
|
<h1>HipChat</h1>
|
||||||
|
|
||||||
|
<div class="jumbotron">
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
<p>If your team uses <a href="https://www.hipchat.com/">HipChat</a>,
|
<p>If your team uses <a href="https://www.hipchat.com/">HipChat</a>,
|
||||||
you can set up {% site_name %} to post status updates directly to an
|
you can set up {% site_name %} to post status updates directly to an
|
||||||
appropriate HipChat room.</p>
|
appropriate HipChat room.</p>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-lg btn-primary">
|
||||||
|
Install HipChat Integration
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
{% site_name %} is a <strong>free</strong> and
|
||||||
|
<a href="https://github.com/healthchecks/healthchecks">open source</a>
|
||||||
|
service for monitoring your cron jobs, background processes and
|
||||||
|
scheduled tasks. Before adding HipChat integration, please log into
|
||||||
|
{% site_name %}:</p>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<form class="form-inline" action="{% url 'hc-login' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="input-group input-group-lg">
|
||||||
|
<div class="input-group-addon">@</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="Email">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-lg btn-primary pull-right">
|
||||||
|
Log In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Setup Guide</h2>
|
<h2>Setup Guide</h2>
|
||||||
|
|
||||||
<div class="row ai-step">
|
<div class="row ai-step">
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<span class="step-no">1</span>
|
<span class="step-no">2</span>
|
||||||
Log into your HipChat account and
|
<p>
|
||||||
pick an appropriate room. From the options menu
|
After {% if request.user.is_authenticated %}{% else %}logging in and{% endif %}
|
||||||
select <strong>Integrations...</strong>
|
clicking on "Install HipChat Integration", you will be
|
||||||
|
asked to log into HipChat.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<img
|
<img
|
||||||
@ -28,16 +75,13 @@
|
|||||||
src="{% static 'img/integrations/setup_hipchat_1.png' %}">
|
src="{% static 'img/integrations/setup_hipchat_1.png' %}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row ai-step">
|
<div class="row ai-step">
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<span class="step-no">2</span>
|
<span class="step-no">3</span>
|
||||||
<p>
|
<p>
|
||||||
From the list of available integrations, select
|
Next, HipChat will let you select the chat room
|
||||||
<strong>Build Your Own</strong>. It's at the very top.
|
for receiving {% site_name %} notifications.
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Give it a descriptive name
|
|
||||||
and click <strong>Create</strong>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
@ -45,13 +89,20 @@
|
|||||||
class="ai-guide-screenshot"
|
class="ai-guide-screenshot"
|
||||||
alt="Screenshot"
|
alt="Screenshot"
|
||||||
src="{% static 'img/integrations/setup_hipchat_2.png' %}">
|
src="{% static 'img/integrations/setup_hipchat_2.png' %}">
|
||||||
</div> </div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row ai-step">
|
<div class="row ai-step">
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<span class="step-no">3</span>
|
<span class="step-no">4</span>
|
||||||
<p>Copy the displayed <strong>URL</strong> and paste it down below.</p>
|
<p>
|
||||||
<p>Save the integration, and it's done!</p>
|
As the final step, HipChat will show you the permissions
|
||||||
|
requested by {% site_name %}. There's only one permission
|
||||||
|
needed–"Send Notification". After clicking on "Approve"
|
||||||
|
you will be redirected back to
|
||||||
|
"Integrations" page on {% site_name %} and see
|
||||||
|
the new integration!
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<img
|
<img
|
||||||
@ -61,35 +112,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<h2>Integration Settings</h2>
|
|
||||||
|
|
||||||
<form method="post" class="form-horizontal" action="{% url 'hc-add-hipchat' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="form-group {{ form.value.css_classes }}">
|
|
||||||
<label for="callback-url" class="col-sm-2 control-label">Callback URL</label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<input
|
|
||||||
id="callback-url"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
name="value"
|
|
||||||
placeholder="https://"
|
|
||||||
value="{{ form.value.value|default:"" }}">
|
|
||||||
|
|
||||||
{% if form.value.errors %}
|
|
||||||
<div class="help-block">
|
|
||||||
{{ form.value.errors|join:"" }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-offset-2 col-sm-10">
|
|
||||||
<button type="submit" class="btn btn-primary">Save Integration</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -23,11 +23,11 @@
|
|||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>
|
<p>
|
||||||
healthchecks.io is a <strong>free</strong> and
|
{% site_name %} is a <strong>free</strong> and
|
||||||
<a href="https://github.com/healthchecks/healthchecks">open source</a>
|
<a href="https://github.com/healthchecks/healthchecks">open source</a>
|
||||||
service for monitoring your cron jobs, background processes and
|
service for monitoring your cron jobs, background processes and
|
||||||
scheduled tasks. Before adding Slack integration, please log into
|
scheduled tasks. Before adding Slack integration, please log into
|
||||||
{% site_root %}:</p>
|
{% site_name %}:</p>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<form class="form-inline" action="{% url 'hc-login' %}" method="post">
|
<form class="form-inline" action="{% url 'hc-login' %}" method="post">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user