@ -1,4 +1,4 @@ | |||
# healthchecks | |||
# Healthchecks | |||
[![Build Status](https://travis-ci.org/healthchecks/healthchecks.svg?branch=master)](https://travis-ci.org/healthchecks/healthchecks) | |||
[![Coverage Status](https://coveralls.io/repos/healthchecks/healthchecks/badge.svg?branch=master&service=github)](https://coveralls.io/github/healthchecks/healthchecks?branch=master) | |||
@ -86,19 +86,19 @@ Configurations settings loaded from environment variables: | |||
| Environment variable | Default value | Notes | |||
| -------------------- | ------------- | ----- | | |||
| [SECRET_KEY](https://docs.djangoproject.com/en/2.1/ref/settings/#secret-key) | `"---"` | |||
| [DEBUG](https://docs.djangoproject.com/en/2.1/ref/settings/#debug) | `True` | Set to `False` for production | |||
| [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.1/ref/settings/#allowed-hosts) | `*` | Separate multiple hosts with commas | |||
| [DEFAULT_FROM_EMAIL](https://docs.djangoproject.com/en/2.1/ref/settings/#default-from-email) | `"[email protected]"` | |||
| [SECRET_KEY](https://docs.djangoproject.com/en/2.2/ref/settings/#secret-key) | `"---"` | |||
| [DEBUG](https://docs.djangoproject.com/en/2.2/ref/settings/#debug) | `True` | Set to `False` for production | |||
| [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.2/ref/settings/#allowed-hosts) | `*` | Separate multiple hosts with commas | |||
| [DEFAULT_FROM_EMAIL](https://docs.djangoproject.com/en/2.2/ref/settings/#default-from-email) | `"[email protected]"` | |||
| USE_PAYMENTS | `False` | |||
| REGISTRATION_OPEN | `True` | |||
| DB | `"sqlite"` | Set to `"postgres"` or `"mysql"` | |||
| [DB_HOST](https://docs.djangoproject.com/en/2.1/ref/settings/#host) | `""` *(empty string)* | |||
| [DB_PORT](https://docs.djangoproject.com/en/2.1/ref/settings/#port) | `""` *(empty string)* | |||
| [DB_NAME](https://docs.djangoproject.com/en/2.1/ref/settings/#name) | `"hc"` (PostgreSQL, MySQL) or `"/path/to/project/hc.sqlite"` (SQLite) | For SQLite, specify the full path to the database file. | |||
| [DB_USER](https://docs.djangoproject.com/en/2.1/ref/settings/#user) | `"postgres"` or `"root"` | |||
| [DB_PASSWORD](https://docs.djangoproject.com/en/2.1/ref/settings/#password) | `""` *(empty string)* | |||
| [DB_CONN_MAX_AGE](https://docs.djangoproject.com/en/2.1/ref/settings/#conn-max-age) | `0` | |||
| [DB_HOST](https://docs.djangoproject.com/en/2.2/ref/settings/#host) | `""` *(empty string)* | |||
| [DB_PORT](https://docs.djangoproject.com/en/2.2/ref/settings/#port) | `""` *(empty string)* | |||
| [DB_NAME](https://docs.djangoproject.com/en/2.2/ref/settings/#name) | `"hc"` (PostgreSQL, MySQL) or `"/path/to/project/hc.sqlite"` (SQLite) | For SQLite, specify the full path to the database file. | |||
| [DB_USER](https://docs.djangoproject.com/en/2.2/ref/settings/#user) | `"postgres"` or `"root"` | |||
| [DB_PASSWORD](https://docs.djangoproject.com/en/2.2/ref/settings/#password) | `""` *(empty string)* | |||
| [DB_CONN_MAX_AGE](https://docs.djangoproject.com/en/2.2/ref/settings/#conn-max-age) | `0` | |||
| DB_SSLMODE | `"prefer"` | PostgreSQL-specific, [details](https://blog.github.com/2018-10-21-october21-incident-report/) | |||
| DB_TARGET_SESSION_ATTRS | `"read-write"` | PostgreSQL-specific, [details](https://www.postgresql.org/docs/10/static/libpq-connect.html#LIBPQ-CONNECT-TARGET-SESSION-ATTRS) | |||
| EMAIL_HOST | `""` *(empty string)* | |||
@ -127,11 +127,13 @@ Configurations settings loaded from environment variables: | |||
| TWILIO_ACCOUNT | `None` | |||
| TWILIO_AUTH | `None` | |||
| TWILIO_FROM | `None` | |||
| TWILIO_USE_WHATSAPP | `"False"` | |||
| PD_VENDOR_KEY | `None` | |||
| TRELLO_APP_KEY | `None` | |||
| MATRIX_HOMESERVER | `None` | |||
| MATRIX_USER_ID | `None` | |||
| MATRIX_ACCESS_TOKEN | `None` | |||
| APPRISE_ENABLED | `"False"` | |||
Some useful settings keys to override are: | |||
@ -268,7 +270,7 @@ There are separate Django management commands for each task: | |||
$ ./manage.py pruneusers | |||
``` | |||
* Remove old records fromt he `api_tokenbucket` table. The TokenBucket | |||
* Remove old records from the `api_tokenbucket` table. The TokenBucket | |||
model is used for rate-limiting login attempts and similar operations. | |||
Any records older than one day can be safely removed. | |||
@ -276,6 +278,15 @@ There are separate Django management commands for each task: | |||
$ ./manage.py prunetokenbucket | |||
``` | |||
* Remove old records from the `api_flip` table. The Flip | |||
objects are used to track status changes of checks, and to calculate | |||
downtime statistics month by month. Flip objects from more than 3 months | |||
ago are not used and can be safely removed. | |||
``` | |||
$ ./manage.py pruneflips | |||
``` | |||
When you first try these commands on your data, it is a good idea to | |||
test them on a copy of your database, not on the live database right away. | |||
In a production setup, you should also have regular, automated database | |||
@ -326,14 +337,27 @@ To enable Discord integration, you will need to: | |||
### Pushover | |||
To enable Pushover integration, you will need to: | |||
Pushover integration works by creating an application on Pushover.net which | |||
is then subscribed to by Healthchecks users. The registration workflow is as follows: | |||
* On Healthchecks, the user adds a "Pushover" integration to a project | |||
* Healthchecks redirects user's browser to a Pushover.net subscription page | |||
* User approves adding the Healthchecks subscription to their Pushover account | |||
* Pushover.net HTTP redirects back to Healthchecks with a subscription token | |||
* Healthchecks saves the subscription token and uses it for sending Pushover | |||
notifications | |||
* register a new application on https://pushover.net/apps/build | |||
* enable subscriptions in your application and make sure to enable the URL | |||
subscription type | |||
* put the application token and the subscription URL in | |||
To enable the Pushover integration, you will need to: | |||
* Register a new application on Pushover via https://pushover.net/apps/build. | |||
* Within the Pushover 'application' configuration, enable subscriptions. | |||
Make sure the subscription type is set to "URL". Also make sure the redirect | |||
URL is configured to point back to the root of the Healthchecks instance | |||
(e.g., `http://healthchecks.example.com/`). | |||
* Put the Pushover application API Token and the Pushover subscription URL in | |||
`PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL` environment | |||
variables | |||
variables. The Pushover subscription URL should look similar to | |||
`https://pushover.net/subscribe/yourAppName-randomAlphaNumericData`. | |||
### Telegram | |||
@ -353,3 +377,48 @@ where to forward channel messages by invoking Telegram's | |||
For this to work, your `SITE_ROOT` needs to be correct and use "https://" | |||
scheme. | |||
### Apprise | |||
To enable Apprise integration, you will need to: | |||
* ensure you have apprise installed in your local environment: | |||
```bash | |||
pip install apprise | |||
``` | |||
* enable the apprise functionality by setting the `APPRISE_ENABLED` environment variable. | |||
## Running in Production | |||
Here is a non-exhaustive list of pointers and things to check before launching a Healthchecks instance | |||
in production. | |||
* Environment variables, settings.py and local_settings.py. | |||
* [DEBUG](https://docs.djangoproject.com/en/2.2/ref/settings/#debug). Make sure it is set to `False`. | |||
* [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.2/ref/settings/#allowed-hosts). Make sure it | |||
contains the correct domain name you want to use. | |||
* Server Errors. When DEBUG=False, Django will not show detailed error pages, and will not print exception | |||
tracebacks to standard output. To receive exception tracebacks in email, | |||
review and edit the [ADMINS](https://docs.djangoproject.com/en/2.2/ref/settings/#admins) and | |||
[SERVER_EMAIL](https://docs.djangoproject.com/en/2.2/ref/settings/#server-email) settings. | |||
Another good option for receiving exception tracebacks is to use [Sentry](https://sentry.io/for/django/). | |||
* Management commands that need to be run during each deployment. | |||
* This project uses [Django Compressor](https://django-compressor.readthedocs.io/en/stable/) | |||
to combine the CSS and JS files. It is configured for offline compression – run the | |||
`manage.py compress` command whenever files in the `/static/` directory change. | |||
* This project uses Django's [staticfiles app](https://docs.djangoproject.com/en/2.2/ref/contrib/staticfiles/). | |||
Run the `manage.py collectstatic` command whenever files in the `/static/` | |||
directory change. This command collects all the static files inside the `static-collected` directory. | |||
Configure your web server to serve files from this directory under the `/static/` prefix. | |||
* Processes that need to be running constantly. | |||
* `manage.py runserver` is intended for development only. Do not use it in production, | |||
instead consider using [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) or | |||
[gunicorn](https://gunicorn.org/). | |||
* Make sure the `manage.py sendalerts` command is running and can survive server restarts. | |||
On modern linux systems, a good option is to | |||
[define a systemd service](https://github.com/healthchecks/healthchecks/issues/273#issuecomment-520560304) for it. | |||
* General | |||
* Make sure the database is secured well and is getting backed up regularly | |||
* Make sure the TLS certificates are secured well and are getting refreshed regularly | |||
* Have monitoring in place to be sure the Healthchecks instance itself is operational | |||
(is accepting pings, is sending out alerts, is not running out of resources). |
@ -6,10 +6,12 @@ from mock import patch | |||
class CloseAccountTestCase(BaseTestCase): | |||
@patch("hc.payments.models.Subscription.cancel") | |||
def test_it_works(self, mock_cancel): | |||
@patch("hc.payments.models.braintree") | |||
def test_it_works(self, mock_braintree): | |||
Check.objects.create(project=self.project, tags="foo a-B_1 baz@") | |||
Subscription.objects.create(user=self.alice, subscription_id="123") | |||
Subscription.objects.create( | |||
user=self.alice, subscription_id="123", customer_id="fake-customer-id" | |||
) | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.post("/accounts/close/") | |||
@ -27,7 +29,10 @@ class CloseAccountTestCase(BaseTestCase): | |||
self.assertFalse(Check.objects.exists()) | |||
# Subscription should have been canceled | |||
self.assertTrue(mock_cancel.called) | |||
self.assertTrue(mock_braintree.Subscription.cancel.called) | |||
# Braintree customer should have been deleted | |||
self.assertTrue(mock_braintree.Customer.delete.called) | |||
# Subscription should be gone | |||
self.assertFalse(Subscription.objects.exists()) | |||
@ -0,0 +1,16 @@ | |||
from django.core.management.base import BaseCommand | |||
from hc.api.models import Flip | |||
from hc.lib.date import month_boundaries | |||
class Command(BaseCommand): | |||
help = "Prune old Flip objects." | |||
def handle(self, *args, **options): | |||
threshold = min(month_boundaries(months=3)) | |||
q = Flip.objects.filter(created__lt=threshold) | |||
n_pruned, _ = q.delete() | |||
return "Done! Pruned %d flips." % n_pruned |
@ -0,0 +1,37 @@ | |||
# Generated by Django 2.2.3 on 2019-07-20 13:50 | |||
from django.db import migrations, models | |||
import django.utils.timezone | |||
class Migration(migrations.Migration): | |||
dependencies = [ | |||
('api', '0061_webhook_values'), | |||
] | |||
operations = [ | |||
migrations.AlterField( | |||
model_name='channel', | |||
name='kind', | |||
field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('pagerteam', 'Pager Team'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix'), ('whatsapp', 'WhatsApp')], max_length=20), | |||
), | |||
migrations.AlterField( | |||
model_name='flip', | |||
name='processed', | |||
field=models.DateTimeField(blank=True, null=True), | |||
), | |||
migrations.AlterField( | |||
model_name='tokenbucket', | |||
name='updated', | |||
field=models.DateTimeField(default=django.utils.timezone.now), | |||
), | |||
migrations.AddIndex( | |||
model_name='check', | |||
index=models.Index(condition=models.Q(_negated=True, status='down'), fields=['alert_after'], name='api_check_aa_not_down'), | |||
), | |||
migrations.AddIndex( | |||
model_name='flip', | |||
index=models.Index(condition=models.Q(processed=None), fields=['processed'], name='api_flip_not_processed'), | |||
), | |||
] |
@ -0,0 +1,23 @@ | |||
# Generated by Django 2.2.5 on 2019-09-03 09:01 | |||
from django.db import migrations, models | |||
class Migration(migrations.Migration): | |||
dependencies = [ | |||
('api', '0062_auto_20190720_1350'), | |||
] | |||
operations = [ | |||
migrations.AddField( | |||
model_name='check', | |||
name='last_duration', | |||
field=models.DurationField(blank=True, null=True), | |||
), | |||
migrations.AlterField( | |||
model_name='channel', | |||
name='kind', | |||
field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('pagerteam', 'Pager Team'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix'), ('whatsapp', 'WhatsApp'), ('apprise', 'Apprise'), ('mattermost', 'Mattermost')], max_length=20), | |||
), | |||
] |
@ -0,0 +1,24 @@ | |||
from datetime import timedelta | |||
from django.utils import timezone | |||
from hc.api.management.commands.prunepings import Command | |||
from hc.api.models import Check, Ping | |||
from hc.test import BaseTestCase | |||
class PrunePingsTestCase(BaseTestCase): | |||
year_ago = timezone.now() - timedelta(days=365) | |||
def test_it_removes_old_pings(self): | |||
self.profile.ping_log_limit = 1 | |||
self.profile.save() | |||
c = Check(project=self.project, n_pings=2) | |||
c.save() | |||
Ping.objects.create(owner=c, n=1) | |||
Ping.objects.create(owner=c, n=2) | |||
Command().handle() | |||
self.assertEqual(Ping.objects.count(), 1) |
@ -0,0 +1,24 @@ | |||
from datetime import timedelta | |||
from django.utils import timezone | |||
from hc.api.management.commands.prunepingsslow import Command | |||
from hc.api.models import Check, Ping | |||
from hc.test import BaseTestCase | |||
class PrunePingsSlowTestCase(BaseTestCase): | |||
year_ago = timezone.now() - timedelta(days=365) | |||
def test_it_removes_old_pings(self): | |||
self.profile.ping_log_limit = 1 | |||
self.profile.save() | |||
c = Check(project=self.project, n_pings=2) | |||
c.save() | |||
Ping.objects.create(owner=c, n=1) | |||
Ping.objects.create(owner=c, n=2) | |||
Command().handle() | |||
self.assertEqual(Ping.objects.count(), 1) |
@ -0,0 +1,29 @@ | |||
from hc.api.models import Channel | |||
from hc.test import BaseTestCase | |||
from django.test.utils import override_settings | |||
@override_settings(APPRISE_ENABLED=True) | |||
class AddAppriseTestCase(BaseTestCase): | |||
def test_instructions_work(self): | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/integrations/add_apprise/") | |||
self.assertContains(r, "Integration Settings", status_code=200) | |||
def test_it_works(self): | |||
form = {"url": "json://example.org"} | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.post("/integrations/add_apprise/", form) | |||
self.assertRedirects(r, "/integrations/") | |||
c = Channel.objects.get() | |||
self.assertEqual(c.kind, "apprise") | |||
self.assertEqual(c.value, "json://example.org") | |||
self.assertEqual(c.project, self.project) | |||
@override_settings(APPRISE_ENABLED=False) | |||
def test_it_requires_client_id(self): | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/integrations/add_apprise/") | |||
self.assertEqual(r.status_code, 404) |
@ -12,20 +12,26 @@ class AddCheckTestCase(BaseTestCase): | |||
def test_it_works(self): | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.post(self.url) | |||
self.assertRedirects(r, self.redirect_url) | |||
check = Check.objects.get() | |||
self.assertEqual(check.project, self.project) | |||
redirect_url = "/checks/%s/details/?new" % check.code | |||
self.assertRedirects(r, redirect_url) | |||
def test_it_handles_unset_current_project(self): | |||
self.profile.current_project = None | |||
self.profile.save() | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.post(self.url) | |||
self.assertRedirects(r, self.redirect_url) | |||
check = Check.objects.get() | |||
self.assertEqual(check.project, self.project) | |||
redirect_url = "/checks/%s/details/?new" % check.code | |||
self.assertRedirects(r, redirect_url) | |||
def test_team_access_works(self): | |||
self.client.login(username="[email protected]", password="password") | |||
self.client.post(self.url) | |||
@ -0,0 +1,23 @@ | |||
from django.test.utils import override_settings | |||
from hc.api.models import Channel | |||
from hc.test import BaseTestCase | |||
class AddMattermostTestCase(BaseTestCase): | |||
def test_instructions_work(self): | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/integrations/add_mattermost/") | |||
self.assertContains(r, "Integration Settings", status_code=200) | |||
def test_it_works(self): | |||
form = {"value": "http://example.org"} | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.post("/integrations/add_mattermost/", form) | |||
self.assertRedirects(r, "/integrations/") | |||
c = Channel.objects.get() | |||
self.assertEqual(c.kind, "mattermost") | |||
self.assertEqual(c.value, "http://example.org") | |||
self.assertEqual(c.project, self.project) |
@ -0,0 +1,70 @@ | |||
from django.test.utils import override_settings | |||
from hc.api.models import Channel | |||
from hc.test import BaseTestCase | |||
TEST_CREDENTIALS = { | |||
"TWILIO_ACCOUNT": "foo", | |||
"TWILIO_AUTH": "foo", | |||
"TWILIO_FROM": "123", | |||
"TWILIO_USE_WHATSAPP": True, | |||
} | |||
@override_settings(**TEST_CREDENTIALS) | |||
class AddWhatsAppTestCase(BaseTestCase): | |||
url = "/integrations/add_whatsapp/" | |||
def test_instructions_work(self): | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get(self.url) | |||
self.assertContains(r, "Get a WhatsApp message") | |||
@override_settings(USE_PAYMENTS=True) | |||
def test_it_warns_about_limits(self): | |||
self.profile.sms_limit = 0 | |||
self.profile.save() | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get(self.url) | |||
self.assertContains(r, "upgrade to a") | |||
def test_it_creates_channel(self): | |||
form = { | |||
"label": "My Phone", | |||
"value": "+1234567890", | |||
"down": "true", | |||
"up": "true", | |||
} | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.post(self.url, form) | |||
self.assertRedirects(r, "/integrations/") | |||
c = Channel.objects.get() | |||
self.assertEqual(c.kind, "whatsapp") | |||
self.assertEqual(c.sms_number, "+1234567890") | |||
self.assertEqual(c.name, "My Phone") | |||
self.assertTrue(c.whatsapp_notify_down) | |||
self.assertTrue(c.whatsapp_notify_up) | |||
self.assertEqual(c.project, self.project) | |||
def test_it_obeys_up_down_flags(self): | |||
form = {"label": "My Phone", "value": "+1234567890"} | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.post(self.url, form) | |||
self.assertRedirects(r, "/integrations/") | |||
c = Channel.objects.get() | |||
self.assertEqual(c.kind, "whatsapp") | |||
self.assertEqual(c.sms_number, "+1234567890") | |||
self.assertEqual(c.name, "My Phone") | |||
self.assertFalse(c.whatsapp_notify_down) | |||
self.assertFalse(c.whatsapp_notify_up) | |||
self.assertEqual(c.project, self.project) | |||
@override_settings(TWILIO_USE_WHATSAPP=False) | |||
def test_it_obeys_use_whatsapp_flag(self): | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get(self.url) | |||
self.assertEqual(r.status_code, 404) |
@ -43,3 +43,8 @@ class DetailsTestCase(BaseTestCase): | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get(self.url) | |||
self.assertEqual(r.status_code, 200) | |||
def test_it_shows_new_check_notice(self): | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get(self.url + "?new") | |||
self.assertContains(r, "Your new check is ready!", status_code=200) |
@ -22,7 +22,7 @@ class LogTestCase(BaseTestCase): | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get(self.url) | |||
self.assertContains(r, "Local Time", status_code=200) | |||
self.assertContains(r, "Browser's time zone", status_code=200) | |||
def test_team_access_works(self): | |||
@ -1,98 +0,0 @@ | |||
# coding: utf-8 | |||
try: | |||
from reportlab.lib.pagesizes import A4 | |||
from reportlab.lib.units import inch | |||
from reportlab.pdfgen.canvas import Canvas | |||
W, H = A4 | |||
except ImportError: | |||
# Don't crash if reportlab is not installed. | |||
Canvas = object | |||
def f(dt): | |||
return dt.strftime("%b. %-d, %Y") | |||
class PdfInvoice(Canvas): | |||
def __init__(self, fileobj): | |||
Canvas.__init__(self, fileobj, pagesize=A4, pageCompression=0) | |||
self.head_y = H - inch * 0.5 | |||
def linefeed(self): | |||
self.head_y -= inch / 8 | |||
def text(self, s, align="left", size=10, bold=False): | |||
self.head_y -= inch / 24 | |||
self.linefeed() | |||
self.setFont("Helvetica-Bold" if bold else "Helvetica", size) | |||
if align == "left": | |||
self.drawString(inch * 0.5, self.head_y, s) | |||
elif align == "right": | |||
self.drawRightString(W - inch * 0.5, self.head_y, s) | |||
elif align == "center": | |||
self.drawCentredString(W / 2, self.head_y, s) | |||
self.head_y -= inch / 24 | |||
def hr(self): | |||
self.setLineWidth(inch / 72 / 8) | |||
self.line(inch * 0.5, self.head_y, W - inch * 0.5, self.head_y) | |||
def row(self, items, align="left", bold=False, size=10): | |||
self.head_y -= inch / 8 | |||
self.linefeed() | |||
self.setFont("Helvetica-Bold" if bold else "Helvetica", size) | |||
self.drawString(inch * 0.5, self.head_y, items[0]) | |||
self.drawString(inch * 3.5, self.head_y, items[1]) | |||
self.drawString(inch * 5.5, self.head_y, items[2]) | |||
self.drawRightString(W - inch * 0.5, self.head_y, items[3]) | |||
self.head_y -= inch / 8 | |||
def render(self, tx, bill_to): | |||
invoice_id = "MS-HC-%s" % tx.id.upper() | |||
self.setTitle(invoice_id) | |||
self.text("SIA Monkey See Monkey Do", size=16) | |||
self.linefeed() | |||
self.text("Gaujas iela 4-2") | |||
self.text("Valmiera, LV-4201, Latvia") | |||
self.text("VAT: LV44103100701") | |||
self.linefeed() | |||
created = f(tx.created_at) | |||
self.text("Date Issued: %s" % created, align="right") | |||
self.text("Invoice Id: %s" % invoice_id, align="right") | |||
self.linefeed() | |||
self.hr() | |||
self.row(["Description", "Start", "End", tx.currency_iso_code], bold=True) | |||
self.hr() | |||
start = f(tx.subscription_details.billing_period_start_date) | |||
end = f(tx.subscription_details.billing_period_end_date) | |||
if tx.currency_iso_code == "USD": | |||
amount = "$%s" % tx.amount | |||
elif tx.currency_iso_code == "EUR": | |||
amount = "€%s" % tx.amount | |||
else: | |||
amount = "%s %s" % (tx.currency_iso_code, tx.amount) | |||
self.row(["healthchecks.io paid plan", start, end, amount]) | |||
self.hr() | |||
self.row(["", "", "", "Total: %s" % amount], bold=True) | |||
self.linefeed() | |||
self.text("Bill to:", bold=True) | |||
for s in bill_to.split("\n"): | |||
self.text(s.strip()) | |||
self.linefeed() | |||
self.showPage() | |||
self.save() |
@ -1,5 +1,6 @@ | |||
from mock import Mock, patch | |||
from django.utils.timezone import now | |||
from hc.payments.models import Subscription | |||
from hc.test import BaseTestCase | |||
@ -15,11 +16,11 @@ class BillingHistoryTestCase(BaseTestCase): | |||
@patch("hc.payments.models.braintree") | |||
def test_it_works(self, mock_braintree): | |||
m1 = Mock(id="abc123", amount=123) | |||
m2 = Mock(id="def456", amount=456) | |||
m1 = Mock(id="abc123", amount=123, created_at=now()) | |||
m2 = Mock(id="def456", amount=456, created_at=now()) | |||
mock_braintree.Transaction.search.return_value = [m1, m2] | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/accounts/profile/billing/history/") | |||
self.assertContains(r, "123") | |||
self.assertContains(r, "def456") | |||
self.assertContains(r, "456") |
@ -1,74 +0,0 @@ | |||
from mock import Mock, patch | |||
from unittest import skipIf | |||
from django.core import mail | |||
from django.utils.timezone import now | |||
from hc.payments.models import Subscription | |||
from hc.test import BaseTestCase | |||
try: | |||
import reportlab | |||
except ImportError: | |||
reportlab = None | |||
class ChargeWebhookTestCase(BaseTestCase): | |||
def setUp(self): | |||
super(ChargeWebhookTestCase, self).setUp() | |||
self.sub = Subscription(user=self.alice) | |||
self.sub.subscription_id = "test-id" | |||
self.sub.customer_id = "test-customer-id" | |||
self.sub.send_invoices = True | |||
self.sub.save() | |||
self.tx = Mock() | |||
self.tx.id = "abc123" | |||
self.tx.customer_details.id = "test-customer-id" | |||
self.tx.created_at = now() | |||
self.tx.currency_iso_code = "USD" | |||
self.tx.amount = 5 | |||
self.tx.subscription_details.billing_period_start_date = now() | |||
self.tx.subscription_details.billing_period_end_date = now() | |||
@skipIf(reportlab is None, "reportlab not installed") | |||
@patch("hc.payments.views.Subscription.objects.by_braintree_webhook") | |||
def test_it_works(self, mock_getter): | |||
mock_getter.return_value = self.sub, self.tx | |||
r = self.client.post("/pricing/charge/") | |||
self.assertEqual(r.status_code, 200) | |||
# See if email was sent | |||
self.assertEqual(len(mail.outbox), 1) | |||
msg = mail.outbox[0] | |||
self.assertEqual(msg.subject, "Invoice from Mychecks") | |||
self.assertEqual(msg.to, ["[email protected]"]) | |||
self.assertEqual(msg.attachments[0][0], "MS-HC-ABC123.pdf") | |||
@patch("hc.payments.views.Subscription.objects.by_braintree_webhook") | |||
def test_it_obeys_send_invoices_flag(self, mock_getter): | |||
mock_getter.return_value = self.sub, self.tx | |||
self.sub.send_invoices = False | |||
self.sub.save() | |||
r = self.client.post("/pricing/charge/") | |||
self.assertEqual(r.status_code, 200) | |||
# It should not send the email | |||
self.assertEqual(len(mail.outbox), 0) | |||
@skipIf(reportlab is None, "reportlab not installed") | |||
@patch("hc.payments.views.Subscription.objects.by_braintree_webhook") | |||
def test_it_uses_invoice_email(self, mock_getter): | |||
mock_getter.return_value = self.sub, self.tx | |||
self.sub.invoice_email = "[email protected]" | |||
self.sub.save() | |||
r = self.client.post("/pricing/charge/") | |||
self.assertEqual(r.status_code, 200) | |||
# See if the email was sent to Alice's accountant: | |||
self.assertEqual(len(mail.outbox), 1) | |||
self.assertEqual(mail.outbox[0].to, ["[email protected]"]) |
@ -7,10 +7,14 @@ from hc.test import BaseTestCase | |||
class GetClientTokenTestCase(BaseTestCase): | |||
@patch("hc.payments.models.braintree") | |||
def test_it_works(self, mock_braintree): | |||
sub = Subscription(user=self.alice) | |||
sub.customer_id = "fake-customer-id" | |||
sub.save() | |||
mock_braintree.ClientToken.generate.return_value = "test-token" | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/pricing/get_client_token/") | |||
r = self.client.get("/pricing/token/") | |||
self.assertContains(r, "test-token", status_code=200) | |||
# A subscription object should have been created | |||
@ -5,23 +5,13 @@ from hc.test import BaseTestCase | |||
class UpdatePaymentMethodTestCase(BaseTestCase): | |||
def _setup_mock(self, mock): | |||
""" Set up Braintree calls that the controller will use. """ | |||
mock.PaymentMethod.create.return_value.is_success = True | |||
mock.PaymentMethod.create.return_value.payment_method.token = "fake" | |||
@patch("hc.payments.models.braintree") | |||
def test_it_retrieves_paypal(self, mock): | |||
self._setup_mock(mock) | |||
mock.paypal_account.PayPalAccount = dict | |||
mock.credit_card.CreditCard = list | |||
mock.PaymentMethod.find.return_value = {"email": "[email protected]"} | |||
self.sub = Subscription(user=self.alice) | |||
self.sub.payment_method_token = "fake-token" | |||
self.sub.save() | |||
Subscription.objects.create(user=self.alice) | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/accounts/profile/billing/payment_method/") | |||
@ -29,65 +19,12 @@ class UpdatePaymentMethodTestCase(BaseTestCase): | |||
@patch("hc.payments.models.braintree") | |||
def test_it_retrieves_cc(self, mock): | |||
self._setup_mock(mock) | |||
mock.paypal_account.PayPalAccount = list | |||
mock.credit_card.CreditCard = dict | |||
mock.PaymentMethod.find.return_value = {"masked_number": "1***2"} | |||
self.sub = Subscription(user=self.alice) | |||
self.sub.payment_method_token = "fake-token" | |||
self.sub.save() | |||
Subscription.objects.create(user=self.alice) | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/accounts/profile/billing/payment_method/") | |||
self.assertContains(r, "1***2") | |||
@patch("hc.payments.models.braintree") | |||
def test_it_creates_payment_method(self, mock): | |||
self._setup_mock(mock) | |||
self.sub = Subscription(user=self.alice) | |||
self.sub.customer_id = "test-customer" | |||
self.sub.save() | |||
self.client.login(username="[email protected]", password="password") | |||
form = {"payment_method_nonce": "test-nonce"} | |||
r = self.client.post("/accounts/profile/billing/payment_method/", form) | |||
self.assertRedirects(r, "/accounts/profile/billing/") | |||
@patch("hc.payments.models.braintree") | |||
def test_it_creates_customer(self, mock): | |||
self._setup_mock(mock) | |||
mock.Customer.create.return_value.is_success = True | |||
mock.Customer.create.return_value.customer.id = "test-customer-id" | |||
self.sub = Subscription(user=self.alice) | |||
self.sub.save() | |||
self.client.login(username="[email protected]", password="password") | |||
form = {"payment_method_nonce": "test-nonce"} | |||
self.client.post("/accounts/profile/billing/payment_method/", form) | |||
self.sub.refresh_from_db() | |||
self.assertEqual(self.sub.customer_id, "test-customer-id") | |||
@patch("hc.payments.models.braintree") | |||
def test_it_updates_subscription(self, mock): | |||
self._setup_mock(mock) | |||
self.sub = Subscription(user=self.alice) | |||
self.sub.customer_id = "test-customer" | |||
self.sub.subscription_id = "fake-id" | |||
self.sub.save() | |||
mock.Customer.create.return_value.is_success = True | |||
mock.Customer.create.return_value.customer.id = "test-customer-id" | |||
self.client.login(username="[email protected]", password="password") | |||
form = {"payment_method_nonce": "test-nonce"} | |||
self.client.post("/accounts/profile/billing/payment_method/", form) | |||
self.assertTrue(mock.Subscription.update.called) |
@ -1,65 +0,0 @@ | |||
from mock import Mock, patch | |||
from unittest import skipIf | |||
from django.utils.timezone import now | |||
from hc.payments.models import Subscription | |||
from hc.test import BaseTestCase | |||
try: | |||
import reportlab | |||
except ImportError: | |||
reportlab = None | |||
class PdfInvoiceTestCase(BaseTestCase): | |||
def setUp(self): | |||
super(PdfInvoiceTestCase, self).setUp() | |||
self.sub = Subscription(user=self.alice) | |||
self.sub.subscription_id = "test-id" | |||
self.sub.customer_id = "test-customer-id" | |||
self.sub.save() | |||
self.tx = Mock() | |||
self.tx.id = "abc123" | |||
self.tx.customer_details.id = "test-customer-id" | |||
self.tx.created_at = now() | |||
self.tx.currency_iso_code = "USD" | |||
self.tx.amount = 5 | |||
self.tx.subscription_details.billing_period_start_date = now() | |||
self.tx.subscription_details.billing_period_end_date = now() | |||
@skipIf(reportlab is None, "reportlab not installed") | |||
@patch("hc.payments.models.braintree") | |||
def test_it_works(self, mock_braintree): | |||
mock_braintree.Transaction.find.return_value = self.tx | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/invoice/pdf/abc123/") | |||
self.assertTrue(b"ABC123" in r.content) | |||
self.assertTrue(b"[email protected]" in r.content) | |||
@patch("hc.payments.models.braintree") | |||
def test_it_checks_customer_id(self, mock_braintree): | |||
tx = Mock() | |||
tx.id = "abc123" | |||
tx.customer_details.id = "test-another-customer-id" | |||
tx.created_at = None | |||
mock_braintree.Transaction.find.return_value = tx | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/invoice/pdf/abc123/") | |||
self.assertEqual(r.status_code, 403) | |||
@skipIf(reportlab is None, "reportlab not installed") | |||
@patch("hc.payments.models.braintree") | |||
def test_it_shows_company_data(self, mock): | |||
self.sub.address_id = "aa" | |||
self.sub.save() | |||
mock.Transaction.find.return_value = self.tx | |||
mock.Address.find.return_value = {"company": "Alice and Partners"} | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/invoice/pdf/abc123/") | |||
self.assertTrue(b"Alice and Partners" in r.content) |
@ -35,6 +35,15 @@ class PricingTestCase(BaseTestCase): | |||
r = self.client.get("/pricing/") | |||
self.assertContains(r, "To manage billing for this project") | |||
def test_it_handles_null_project(self): | |||
self.profile.current_project = None | |||
self.profile.save() | |||
self.client.login(username="[email protected]", password="password") | |||
r = self.client.get("/pricing/") | |||
self.assertContains(r, "Unlimited Team Members") | |||
def test_it_shows_active_plan(self): | |||
self.sub = Subscription(user=self.alice) | |||
self.sub.subscription_id = "test-id" | |||
@ -1,6 +1,6 @@ | |||
croniter==0.3.30 | |||
Django==2.2.1 | |||
django_compressor==2.2 | |||
psycopg2==2.7.5 | |||
Django==2.2.6 | |||
django_compressor==2.3 | |||
psycopg2==2.8.3 | |||
pytz==2019.1 | |||
requests==2.22.0 |
@ -1,204 +0,0 @@ | |||
// Native Javascript for Bootstrap 3 | Collapse | |||
// by dnp_theme | |||
(function(factory){ | |||
// CommonJS/RequireJS and "native" compatibility | |||
if(typeof module !== "undefined" && typeof exports == "object") { | |||
// A commonJS/RequireJS environment | |||
if(typeof window != "undefined") { | |||
// Window and document exist, so return the factory's return value. | |||
module.exports = factory(); | |||
} else { | |||
// Let the user give the factory a Window and Document. | |||
module.exports = factory; | |||
} | |||
} else { | |||
// Assume a traditional browser. | |||
window.Collapse = factory(); | |||
} | |||
})(function(){ | |||
// COLLAPSE DEFINITION | |||
// =================== | |||
var Collapse = function( element, options ) { | |||
options = options || {}; | |||
this.btn = typeof element === 'object' ? element : document.querySelector(element); | |||
this.accordion = null; | |||
this.collapse = null; | |||
this.duration = 300; // default collapse transition duration | |||
this.options = {}; | |||
this.options.duration = /ie/.test(document.documentElement.className) ? 0 : (options.duration || this.duration); | |||
this.init(); | |||
} | |||
// COLLAPSE METHODS | |||
// ================ | |||
Collapse.prototype = { | |||
init : function() { | |||
this.actions(); | |||
this.btn.addEventListener('click', this.toggle, false); | |||
// allows the collapse to expand | |||
// ** when window gets resized | |||
// ** or via internal clicks handers such as dropwowns or any other | |||
document.addEventListener('click', this.update, false); | |||
window.addEventListener('resize', this.update, false) | |||
}, | |||
actions : function() { | |||
var self = this; | |||
this.toggle = function(e) { | |||
self.btn = self.getTarget(e).btn; | |||
self.collapse = self.getTarget(e).collapse; | |||
if (!/in/.test(self.collapse.className)) { | |||
self.open(e) | |||
} else { | |||
self.close(e) | |||
} | |||
}, | |||
this.close = function(e) { | |||
e.preventDefault(); | |||
self.btn = self.getTarget(e).btn; | |||
self.collapse = self.getTarget(e).collapse; | |||
self._close(self.collapse); | |||
self.btn.className = self.btn.className.replace(' collapsed',''); | |||
}, | |||
this.open = function(e) { | |||
e.preventDefault(); | |||
self.btn = self.getTarget(e).btn; | |||
self.collapse = self.getTarget(e).collapse; | |||
self.accordion = self.btn.getAttribute('data-parent') && self.getClosest(self.btn, self.btn.getAttribute('data-parent')); | |||
self._open(self.collapse); | |||
self.btn.className += ' collapsed'; | |||
if ( self.accordion !== null ) { | |||
var active = self.accordion.querySelectorAll('.collapse.in'), al = active.length, i = 0; | |||
for (i;i<al;i++) { | |||
if ( active[i] !== self.collapse) self._close(active[i]); | |||
} | |||
} | |||
}, | |||
this._open = function(c) { | |||
c.className += ' in'; | |||
c.style.height = 0; | |||
c.style.overflow = 'hidden'; | |||
c.setAttribute('area-expanded','true'); | |||
// the collapse MUST have a childElement div to wrap them all inside, just like accordion/well | |||
var oh = this.getMaxHeight(c).oh, br = this.getMaxHeight(c).br; | |||
c.style.height = oh + br + 'px'; | |||
setTimeout(function() { | |||
c.style.overflow = ''; | |||
}, self.options.duration) | |||
}, | |||
this._close = function(c) { | |||
c.style.overflow = 'hidden'; | |||
c.style.height = 0; | |||
setTimeout(function() { | |||
c.className = c.className.replace(' in',''); | |||
c.style.overflow = ''; | |||
c.setAttribute('area-expanded','false'); | |||
}, self.options.duration) | |||
}, | |||
this.update = function(e) { | |||
var evt = e.type, tg = e.target, closest = self.getClosest(tg,'.collapse'), | |||
itms = document.querySelectorAll('.collapse.in'), i = 0, il = itms.length; | |||
for (i;i<il;i++) { | |||
var itm = itms[i], oh = self.getMaxHeight(itm).oh, br = self.getMaxHeight(itm).br; | |||
if ( evt === 'resize' && !/ie/.test(document.documentElement.className) ){ | |||
setTimeout(function() { | |||
itm.style.height = oh + br + 'px'; | |||
}, self.options.duration) | |||
} else if ( evt === 'click' && closest === itm ) { | |||
itm.style.height = oh + br + 'px'; | |||
} | |||
} | |||
}, | |||
this.getMaxHeight = function(l) { // get collapse trueHeight and border | |||
var t = l.children[0]; | |||
var cs = l.currentStyle || window.getComputedStyle(l); | |||
return { | |||
oh : getOuterHeight(t), | |||
br : parseInt(cs.borderTop||0) + parseInt(cs.borderBottom||0) | |||
} | |||
}, | |||
this.getTarget = function(e) { | |||
var t = e.currentTarget || e.srcElement, | |||
h = t.href && t.getAttribute('href').replace('#',''), | |||
d = t.getAttribute('data-target') && ( t.getAttribute('data-target') ), | |||
id = h || ( d && /#/.test(d)) && d.replace('#',''), | |||
cl = (d && d.charAt(0) === '.') && d, //the navbar collapse trigger targets a class | |||
c = id && document.getElementById(id) || cl && document.querySelector(cl); | |||
return { | |||
btn : t, | |||
collapse : c | |||
} | |||
}, | |||
this.getClosest = function (el, s) { //el is the element and s the selector of the closest item to find | |||
// source http://gomakethings.com/climbing-up-and-down-the-dom-tree-with-vanilla-javascript/ | |||
var f = s.charAt(0); | |||
for ( ; el && el !== document; el = el.parentNode ) {// Get closest match | |||
if ( f === '.' ) {// If selector is a class | |||
if ( document.querySelector(s) !== undefined ) { return el; } | |||
} | |||
if ( f === '#' ) { // If selector is an ID | |||
if ( el.id === s.substr(1) ) { return el; } | |||
} | |||
} | |||
return false; | |||
} | |||
} | |||
} | |||
var getOuterHeight = function (el) { | |||
var s = el && el.currentStyle || window.getComputedStyle(el), | |||
mtp = /px/.test(s.marginTop) ? Math.round(s.marginTop.replace('px','')) : 0, | |||
mbp = /px/.test(s.marginBottom) ? Math.round(s.marginBottom.replace('px','')) : 0, | |||
mte = /em/.test(s.marginTop) ? Math.round(s.marginTop.replace('em','') * parseInt(s.fontSize)) : 0, | |||
mbe = /em/.test(s.marginBottom) ? Math.round(s.marginBottom.replace('em','') * parseInt(s.fontSize)) : 0; | |||
return el.offsetHeight + parseInt( mtp ) + parseInt( mbp ) + parseInt( mte ) + parseInt( mbe ) //we need an accurate margin value | |||
} | |||
// COLLAPSE DATA API | |||
// ================= | |||
var Collapses = document.querySelectorAll('[data-toggle="collapse"]'), i = 0, cll = Collapses.length; | |||
for (i;i<cll;i++) { | |||
var item = Collapses[i], options = {}; | |||
options.duration = item.getAttribute('data-duration'); | |||
new Collapse(item,options); | |||
} | |||
//we must add the height to the pre-opened collapses | |||
window.addEventListener('load', function() { | |||
var openedCollapses = document.querySelectorAll('.collapse'), i = 0, ocl = openedCollapses.length; | |||
for (i;i<ocl;i++) { | |||
var oc = openedCollapses[i]; | |||
if (/in/.test(oc.className)) { | |||
var s = oc.currentStyle || window.getComputedStyle(oc); | |||
var oh = getOuterHeight(oc.children[0]); | |||
var br = parseInt(s.borderTop||0) + parseInt(s.borderBottom||0); | |||
oc.style.height = oh + br + 'px'; | |||
} | |||
} | |||
}); | |||
return Collapse; | |||
}); |
@ -1,117 +0,0 @@ | |||
// Native Javascript for Bootstrap 3 | Tab | |||
// by dnp_theme | |||
(function(factory){ | |||
// CommonJS/RequireJS and "native" compatibility | |||
if(typeof module !== "undefined" && typeof exports == "object") { | |||
// A commonJS/RequireJS environment | |||
if(typeof window != "undefined") { | |||
// Window and document exist, so return the factory's return value. | |||
module.exports = factory(); | |||
} else { | |||
// Let the user give the factory a Window and Document. | |||
module.exports = factory; | |||
} | |||
} else { | |||
// Assume a traditional browser. | |||
window.Tab = factory(); | |||
} | |||
})(function(){ | |||
// TAB DEFINITION | |||
// =================== | |||
var Tab = function( element,options ) { | |||
options = options || {}; | |||
this.tab = typeof element === 'object' ? element : document.querySelector(element); | |||
this.tabs = this.tab.parentNode.parentNode; | |||
this.dropdown = this.tabs.querySelector('.dropdown'); | |||
if ( this.tabs.classList.contains('dropdown-menu') ) { | |||
this.dropdown = this.tabs.parentNode; | |||
this.tabs = this.tabs.parentNode.parentNode; | |||
} | |||
this.options = {}; | |||
// default tab transition duration | |||
this.duration = 150; | |||
this.options.duration = document.documentElement.classList.contains('ie') ? 0 : (options.duration || this.duration); | |||
this.init(); | |||
} | |||
// TAB METHODS | |||
// ================ | |||
Tab.prototype = { | |||
init : function() { | |||
var self = this; | |||
self.actions(); | |||
self.tab.addEventListener('click', self.action, false); | |||
}, | |||
actions : function() { | |||
var self = this; | |||
this.action = function(e) { | |||
e = e || window.e; | |||
var next = e.target; //the tab we clicked is now the next tab | |||
var nextContent = document.getElementById(next.getAttribute('href').replace('#','')); //this is the actual object, the next tab content to activate | |||
var activeTab = self.getActiveTab(); | |||
var activeContent = self.getActiveContent(); | |||
//toggle "active" class name | |||
activeTab.classList.remove('active'); | |||
next.parentNode.classList.add('active'); | |||
//handle dropdown menu "active" class name | |||
if ( !self.tab.parentNode.parentNode.classList.contains('dropdown-menu')){ | |||
self.dropdown && self.dropdown.classList.remove('active'); | |||
} else { | |||
self.dropdown && self.dropdown.classList.add('active'); | |||
} | |||
//1. hide current active content first | |||
activeContent.classList.remove('in'); | |||
setTimeout(function() { | |||
//2. toggle current active content from view | |||
activeContent.classList.remove('active'); | |||
nextContent.classList.add('active'); | |||
}, self.options.duration); | |||
setTimeout(function() { | |||
//3. show next active content | |||
nextContent.classList.add('in'); | |||
}, self.options.duration*2); | |||
e.preventDefault(); | |||
}, | |||
this.getActiveTab = function() { | |||
var activeTabs = self.tabs.querySelectorAll('.active'); | |||
if ( activeTabs.length === 1 && !activeTabs[0].classList.contains('dropdown') ) { | |||
return activeTabs[0] | |||
} else if ( activeTabs.length > 1 ) { | |||
return activeTabs[activeTabs.length-1] | |||
} | |||
}, | |||
this.getActiveContent = function() { | |||
var a = self.getActiveTab().getElementsByTagName('A')[0].getAttribute('href').replace('#',''); | |||
return a && document.getElementById(a) | |||
} | |||
} | |||
} | |||
// TAB DATA API | |||
// ================= | |||
var Tabs = document.querySelectorAll("[data-toggle='tab'], [data-toggle='pill']"), tbl = Tabs.length, i=0; | |||
for ( i;i<tbl;i++ ) { | |||
var tab = Tabs[i], options = {}; | |||
options.duration = tab.getAttribute('data-duration') && tab.getAttribute('data-duration') || false; | |||
new Tab(tab,options); | |||
} | |||
return Tab; | |||
}); |
@ -0,0 +1,82 @@ | |||
{% load humanize hc_extras %} | |||
{% regroup checks by project as groups %} | |||
<table style="margin: 0; width: 100%; font-size: 16px;" cellpadding="0" cellspacing="0"> | |||
{% for group in groups %} | |||
<tr> | |||
<td colspan="2" style="font-weight: bold; padding: 32px 8px 8px 8px; color: #333;"> | |||
{{ group.grouper|mangle_link }} | |||
</td> | |||
{% for dt in month_boundaries %} | |||
<td style="padding: 32px 8px 8px 8px; margin: 0; font-size: 12px; color: #9BA2AB; font-family: Helvetica, Arial, sans-serif;"> | |||
{{ dt|date:"N Y"}} | |||
</td> | |||
{% endfor %} | |||
</tr> | |||
{% for check in group.list|sortchecks:sort %} | |||
<tr> | |||
<td style="border-top: 1px solid #EDEFF2; padding: 16px 8px;"> | |||
<table cellpadding="0" cellspacing="0"> | |||
<tr> | |||
{% if check.get_status == "new" %} | |||
<td style="background: #AAA; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; margin: 0; border-radius: 3px;">NEW</td> | |||
{% elif check.get_status == "paused" %} | |||
<td style="background: #AAA; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">PAUSED</td> | |||
{% elif check.get_status == "grace" %} | |||
<td style="background: #f0ad4e; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">LATE</td> | |||
{% elif check.get_status == "up" %} | |||
<td style="background: #5cb85c; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">UP</td> | |||
{% elif check.get_status == "started" %} | |||
<td style="background: #5cb85c; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">STARTED</td> | |||
{% elif check.get_status == "down" %} | |||
<td style="background: #d9534f; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">DOWN</td> | |||
{% endif %} | |||
</tr> | |||
</table> | |||
</td> | |||
<td style="border-top: 1px solid #EDEFF2; padding: 16px 8px; font-family: Helvetica, Arial, sans-serif;"> | |||
{% if check.name %} | |||
{% if check.name|length > 20 %} | |||
<small>{{ check.name|mangle_link }}</small> | |||
{% else %} | |||
{{ check.name|mangle_link }} | |||
{% endif %} | |||
{% else %} | |||
<span style="color: #74787E; font-style: italic;">unnamed</span> | |||
{% endif %} | |||
{% if check.tags %} | |||
<br /> | |||
<table cellpadding="0" cellspacing="0"> | |||
<tr> | |||
{% for tag in check.tags_list %} | |||
<td style="padding-right: 4px"> | |||
<table cellpadding="0" cellspacing="0"> | |||
<tr> | |||
<td style="background: #eee; font-family: Helvetica, Arial, sans-serif; font-size: 10px; line-height: 10px; color: #555; padding: 4px; margin: 0; border-radius: 2px;"> | |||
{{ tag|mangle_link }} | |||
</td> | |||
</tr> | |||
</table> | |||
</td> | |||
{% endfor %} | |||
</tr> | |||
</table> | |||
{% endif %} | |||
</td> | |||
{% for boundary, seconds, count in check.downtimes %} | |||
{% if count %} | |||
<td style="border-top: 1px solid #EDEFF2; padding: 16px 8px; font-family: Helvetica, Arial, sans-serif;"> | |||
{{ count }} downtime{{ count|pluralize }}, | |||
<br /> | |||
{{ seconds|hc_approx_duration }} total | |||
</td> | |||
{% else %} | |||
<td style="border-top: 1px solid #EDEFF2; padding: 16px 8px; font-family: Helvetica, Arial, sans-serif; color: #9BA2AB;"> | |||
All good! | |||
</td> | |||
{% endif %} | |||
{% endfor %} | |||
</tr> | |||
{% endfor %} | |||
{% endfor %} | |||
</table> | |||
<br /> |
@ -0,0 +1,16 @@ | |||
{% load hc_extras %} | |||
<table class="table"> | |||
{% for boundary, seconds, count in downtimes reversed %} | |||
<tr> | |||
<th>{{ boundary|date:"N Y"}}</th> | |||
<td> | |||
{% if count %} | |||
{{ count }} downtime{{ count|pluralize }}, | |||
{{ seconds|hc_approx_duration }} total | |||
{% else %} | |||
All good! | |||
{% endif %} | |||
</td> | |||
</tr> | |||
{% endfor %} | |||
</table> |
@ -1,9 +1,14 @@ | |||
{% load humanize %} | |||
{% load humanize hc_extras %} | |||
{% if check.last_ping %} | |||
{{ check.last_ping|naturaltime }} | |||
{% if check.has_confirmation_link %} | |||
<br /><span class="label label-confirmation">confirmation link</span> | |||
{% elif check.clamped_last_duration %} | |||
<br /> | |||
<span class="checks-subline-duration"> | |||
<span class="icon-timer"></span> {{ check.clamped_last_duration|hms }} | |||
</span> | |||
{% endif %} | |||
{% else %} | |||
Never |
@ -0,0 +1,29 @@ | |||
<div class="highlight"><pre><span></span><span class="p">{</span> | |||
<span class="nt">"checks"</span><span class="p">:</span> <span class="p">[</span> | |||
<span class="p">{</span> | |||
<span class="nt">"desc"</span><span class="p">:</span> <span class="s2">"Longer free-form description goes here"</span><span class="p">,</span> | |||
<span class="nt">"grace"</span><span class="p">:</span> <span class="mi">900</span><span class="p">,</span> | |||
<span class="nt">"last_ping"</span><span class="p">:</span> <span class="s2">"2017-01-04T13:24:39.903464+00:00"</span><span class="p">,</span> | |||
<span class="nt">"n_pings"</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> | |||
<span class="nt">"name"</span><span class="p">:</span> <span class="s2">"Api test 1"</span><span class="p">,</span> | |||
<span class="nt">"status"</span><span class="p">:</span> <span class="s2">"up"</span><span class="p">,</span> | |||
<span class="nt">"tags"</span><span class="p">:</span> <span class="s2">"foo"</span><span class="p">,</span> | |||
<span class="nt">"timeout"</span><span class="p">:</span> <span class="mi">3600</span><span class="p">,</span> | |||
<span class="nt">"unique_key"</span><span class="p">:</span> <span class="s2">"2872190d95224bad120f41d3c06aab94b8175bb6"</span> | |||
<span class="p">},</span> | |||
<span class="p">{</span> | |||
<span class="nt">"desc"</span><span class="p">:</span> <span class="s2">""</span><span class="p">,</span> | |||
<span class="nt">"grace"</span><span class="p">:</span> <span class="mi">3600</span><span class="p">,</span> | |||
<span class="nt">"last_ping"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span> | |||
<span class="nt">"n_pings"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> | |||
<span class="nt">"name"</span><span class="p">:</span> <span class="s2">"Api test 2"</span><span class="p">,</span> | |||
<span class="nt">"next_ping"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span> | |||
<span class="nt">"schedule"</span><span class="p">:</span> <span class="s2">"0/10 * * * *"</span><span class="p">,</span> | |||
<span class="nt">"status"</span><span class="p">:</span> <span class="s2">"new"</span><span class="p">,</span> | |||
<span class="nt">"tags"</span><span class="p">:</span> <span class="s2">"bar baz"</span><span class="p">,</span> | |||
<span class="nt">"tz"</span><span class="p">:</span> <span class="s2">"UTC"</span><span class="p">,</span> | |||
<span class="nt">"unique_key"</span><span class="p">:</span> <span class="s2">"9b5fc29129560ff2c5c1803803a7415e4f80cf7e"</span> | |||
<span class="p">}</span> | |||
<span class="p">]</span> | |||
<span class="p">}</span> | |||
</pre></div> |
@ -0,0 +1,28 @@ | |||
{ | |||
"checks": [ | |||
{ | |||
"desc": "Longer free-form description goes here", | |||
"grace": 900, | |||
"last_ping": "2017-01-04T13:24:39.903464+00:00", | |||
"n_pings": 1, | |||
"name": "Api test 1", | |||
"status": "up", | |||
"tags": "foo", | |||
"timeout": 3600, | |||
"unique_key": "2872190d95224bad120f41d3c06aab94b8175bb6" | |||
}, | |||
{ | |||
"desc": "", | |||
"grace": 3600, | |||
"last_ping": null, | |||
"n_pings": 0, | |||
"name": "Api test 2", | |||
"next_ping": null, | |||
"schedule": "0/10 * * * *", | |||
"status": "new", | |||
"tags": "bar baz", | |||
"tz": "UTC", | |||
"unique_key": "9b5fc29129560ff2c5c1803803a7415e4f80cf7e" | |||
} | |||
] | |||
} |
@ -0,0 +1,49 @@ | |||
{% extends "base.html" %} | |||
{% load humanize static hc_extras %} | |||
{% block title %}Add Apprise - {% site_name %}{% endblock %} | |||
{% block content %} | |||
<div class="row"> | |||
<div class="col-sm-12"> | |||
<h1>Apprise</h1> | |||
<p> | |||
Identify as many Apprise URLs as you wish. You can use a comma (,) to identify | |||
more than on URL if you wish to. | |||
For a detailed list of all supported Apprise Notification URLs simply | |||
<a href="https://github.com/caronc/apprise#popular-notification-services" >click here</a>. | |||
</p> | |||
<h2>Integration Settings</h2> | |||
<form method="post" class="form-horizontal"> | |||
{% csrf_token %} | |||
<div class="form-group {{ form.room_id.css_classes }}"> | |||
<label for="url" class="col-sm-2 control-label">Apprise URL</label> | |||
<div class="col-sm-6"> | |||
<input | |||
id="url" | |||
type="text" | |||
class="form-control" | |||
name="url" | |||
value="{{ form.url.value|default:"" }}"> | |||
{% if form.url.errors %} | |||
<div class="help-block"> | |||
{{ form.url.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> | |||
{% endblock %} |
@ -0,0 +1,97 @@ | |||
{% extends "base.html" %} | |||
{% load humanize static hc_extras %} | |||
{% block title %}Add Mattermost - {% site_name %}{% endblock %} | |||
{% block content %} | |||
<div class="row"> | |||
<div class="col-sm-12"> | |||
<h1>Mattermost</h1> | |||
<p>If your team uses <a href="https://mattermost.com/">Mattermost</a>, you can set | |||
up {% site_name %} to post status updates directly to an appropriate | |||
Mattermost channel.</p> | |||
<h2>Setup Guide</h2> | |||
<div class="row ai-step"> | |||
<div class="col-sm-6"> | |||
<span class="step-no">1</span> | |||
Log into your Mattermost account and | |||
select <strong>Integrations</strong> in the | |||
hamburger menu. | |||
</div> | |||
<div class="col-sm-6"> | |||
<img | |||
class="ai-guide-screenshot" | |||
alt="Screenshot" | |||
src="{% static 'img/integrations/setup_mattermost_1.png' %}"> | |||
</div> | |||
</div> | |||
<div class="row ai-step"> | |||
<div class="col-sm-6"> | |||
<span class="step-no">2</span> | |||
<p> | |||
In the "Integrations" screen, select <strong>Incoming Webhook</strong> | |||
and then <strong>Add Incoming Webhook</strong>. | |||
</p> | |||
<p>Fill in the form and hit "Save".</p> | |||
</div> | |||
<div class="col-sm-6"> | |||
<img | |||
class="ai-guide-screenshot" | |||
alt="Screenshot" | |||
src="{% static 'img/integrations/setup_mattermost_2.png' %}"> | |||
</div> | |||
</div> | |||
<div class="row ai-step"> | |||
<div class="col-sm-6"> | |||
<span class="step-no">3</span> | |||
<p>Copy the displayed <strong>URL</strong> and paste it down below.</p> | |||
<p>Save the integration, and it's done!</p> | |||
</div> | |||
<div class="col-sm-6"> | |||
<img | |||
class="ai-guide-screenshot" | |||
alt="Screenshot" | |||
src="{% static 'img/integrations/setup_mattermost_3.png' %}"> | |||
</div> | |||
</div> | |||
<h2>Integration Settings</h2> | |||
<form method="post" class="form-horizontal" action="{% url 'hc-add-mattermost' %}"> | |||
{% csrf_token %} | |||
<div class="form-group {{ form.value.css_classes }}"> | |||
<label for="callback-url" class="col-sm-2 control-label"> | |||
Webhook 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> | |||
{% endblock %} |
@ -0,0 +1,109 @@ | |||
{% extends "base.html" %} | |||
{% load humanize static hc_extras %} | |||
{% block title %}Notification Channels - {% site_name %}{% endblock %} | |||
{% block content %} | |||
<div class="row"> | |||
<div class="col-sm-12"> | |||
<h1>WhatsApp</h1> | |||
<p> | |||
Get a WhatsApp message when a check goes up or down. | |||
</p> | |||
{% if show_pricing and profile.sms_limit == 0 %} | |||
<p class="alert alert-info"> | |||
<strong>Paid plan required.</strong> | |||
WhatsApp messaging is not available on the free plan–sending the messages | |||
cost too much! Please upgrade to a | |||
<a href="{% url 'hc-billing' %}">paid plan</a> to enable WhatsApp messaging. | |||
</p> | |||
{% endif %} | |||
<h2>Integration Settings</h2> | |||
<form method="post" class="form-horizontal" action="{% url 'hc-add-whatsapp' %}"> | |||
{% csrf_token %} | |||
<div class="form-group {{ form.label.css_classes }}"> | |||
<label for="id_label" class="col-sm-2 control-label">Label</label> | |||
<div class="col-sm-6"> | |||
<input | |||
id="id_label" | |||
type="text" | |||
class="form-control" | |||
name="label" | |||
placeholder="Alice's Phone" | |||
value="{{ form.label.value|default:"" }}"> | |||
{% if form.label.errors %} | |||
<div class="help-block"> | |||
{{ form.label.errors|join:"" }} | |||
</div> | |||
{% else %} | |||
<span class="help-block"> | |||
Optional. If you add multiple phone numbers, | |||
the labels will help you tell them apart. | |||
</span> | |||
{% endif %} | |||
</div> | |||
</div> | |||
<div class="form-group {{ form.value.css_classes }}"> | |||
<label for="id_number" class="col-sm-2 control-label">Phone Number</label> | |||
<div class="col-sm-3"> | |||
<input | |||
id="id_number" | |||
type="tel" | |||
class="form-control" | |||
name="value" | |||
placeholder="+1234567890" | |||
value="{{ form.value.value|default:"" }}"> | |||
{% if form.value.errors %} | |||
<div class="help-block"> | |||
{{ form.value.errors|join:"" }} | |||
</div> | |||
{% else %} | |||
<span class="help-block"> | |||
Make sure the phone number starts with "+" and has the | |||
country code. | |||
</span> | |||
{% endif %} | |||
</div> | |||
</div> | |||
<div id="add-email-notify-group" class="form-group"> | |||
<label class="col-sm-2 control-label">Notify When</label> | |||
<div class="col-sm-10"> | |||
<label class="checkbox-container"> | |||
<input | |||
type="checkbox" | |||
name="down" | |||
value="true" | |||
{% if form.down.value %} checked {% endif %}> | |||
<span class="checkmark"></span> | |||
A check goes <strong>down</strong> | |||
</label> | |||
<label class="checkbox-container"> | |||
<input | |||
type="checkbox" | |||
name="up" | |||
value="true" | |||
{% if form.up.value %} checked {% endif %}> | |||
<span class="checkmark"></span> | |||
A check goes <strong>up</strong> | |||
</label> | |||
</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> | |||
{% endblock %} |
@ -0,0 +1,5 @@ | |||
{% load humanize %} | |||
{{ check.name_then_code }} is {{ check.status|upper }}. | |||
{% if check.status == "down" %} | |||
Last ping was {{ check.last_ping|naturaltime }}. | |||
{% endif %} |
@ -0,0 +1 @@ | |||
{{ check.name_then_code }} is {{ check.status|upper }} |