Compare commits

...

1122 Commits
py2 ... master

Author SHA1 Message Date
Pēteris Caune
c196dc16d7
Fix latin-1 handling in webhook header values 2021-08-10 21:14:05 +03:00
Pēteris Caune
b43612806f
Fix dark mode bug in selectpicker widgets 2021-08-10 16:47:47 +03:00
Pēteris Caune
544ec7ea69
Add handling for non-latin-1 characters in webhook headers 2021-08-10 10:36:58 +03:00
Pēteris Caune
78113e1aea
Improve "Grace Time" description in docs
cc: #547
2021-08-09 17:52:20 +03:00
Pēteris Caune
74f56a5501
Improve the note about start signals and alerting logic
cc: #547
2021-08-06 16:10:06 +03:00
Pēteris Caune
2a9bc42dd4
Update Changelog for v1.22.0 release 2021-08-06 14:27:15 +03:00
Pēteris Caune
af7e8fc949
Fix the login view to handle already authenticated users
If an already authenticated user visits /accounts/login/,
Healthchecks will now redirect them to their dashboard
instead of showing the login form.
2021-08-06 13:54:12 +03:00
Pēteris Caune
7252f2f101
Fix _allow_redirect function to reject absolute URLs
This fixes a security issue:
- attacker can crafts a redirect URL to an external site
- attacker gets victim to click on it
- victim logs in
- after login, Healthchecks redirects victim to the external site

The _allow_redirect function now additionally
requires the redirect URL is relative (has no scheme or domain).
2021-08-06 13:34:40 +03:00
Pēteris Caune
f85aec225d
Fix redirect-after-login when using TOTP
If user has both WebAuthn and TOTP configured,
when logging in, they will be asked to choose between
"Use security keys" and "Use authenticator app".
The "Use authenticator app" is a link to a different
page (/accounts/login/two_factor/totp/). This commit makes
sure the ?next= query parameter is preserved when navigating
to that page.

For reference, the ?next= query parameter is the URL we should
redirect to after a successful login. Use case:
User is logged out. They click on a bookmarked "Check Details"
link. They get redirected to the login form. After
entering username & password and completing 2FA,
they get redirected to the "Check Details" page they
originally wanted to visit.
2021-08-06 12:09:41 +03:00
Pēteris Caune
e6427995b7
Add Whitenoise and improve README
Fixes: #548
2021-08-05 18:06:47 +03:00
Pēteris Caune
ca3afa33f9
Add auth method selection step
This has dual purpose:

* if user has both WebAuthn and TOTP set up, they can choose
  between the two as equal options.
* we initiate WebAuthn flow only after an explicit user action
  (button press). This may help with authentication failures
  on recent MacOS, iOS and iPadOS versions [1]

[1] https://support.yubico.com/hc/en-us/articles/360022004600-No-reaction-when-using-WebAuthn-on-macOS-iOS-and-iPadOS
2021-08-05 16:27:06 +03:00
Pēteris Caune
f3af13654e
Refactor email sending functions to allow customization
For example, if we need to use a custom From: address,
we can now do:

    m = make_message("template-name", recipient, ctx)
    m.from_email = "...."  # customize here
    send(m)
2021-08-05 14:13:25 +03:00
Pēteris Caune
fca600659d
Improve hc.lib.emails.send()
- add optional `from_email` argument
- add test cases that exercise the retry loop
2021-08-03 19:02:25 +03:00
Pēteris Caune
c3d458f6f0
Fix the unsubscribe_reports view to handle already deleted users 2021-08-02 12:51:05 +03:00
Pēteris Caune
934099510d
Upgrade to Django 3.2.6 2021-08-02 10:51:31 +03:00
Pēteris Caune
d60d8a43b6
Add protection against TOTP code reuse 2021-07-30 18:17:21 +03:00
Pēteris Caune
8ed5e93cd2
Add rate limiting for TOTP auth attempts 2021-07-30 17:30:28 +03:00
Pēteris Caune
222722569e
Add support for 2FA using TOTP
Fixes: #354
2021-07-30 16:43:23 +03:00
Pēteris Caune
0d9d094882
Update docs with the Manager role 2021-07-26 15:55:08 +03:00
Pēteris Caune
dfa6f404e6
Improve the "Invite a Team Member" dialog 2021-07-26 15:21:45 +03:00
Pēteris Caune
bbd2786e0f
Optimize queries and fix team member sorting 2021-07-26 14:27:03 +03:00
Pēteris Caune
74427ba3f1
Fix wording in the "Team size limit reached" message 2021-07-26 13:12:06 +03:00
Pēteris Caune
e1c3beb4e9
Add test cases for manager operations 2021-07-26 13:07:05 +03:00
Pēteris Caune
4f83f8c06b
Fix a 403 when transferring a project to a read-only team member 2021-07-26 12:50:43 +03:00
swoga
9640d2242f feat: add manager role 2021-07-26 12:26:06 +03:00
Pēteris Caune
ce9ff3ac42
Add a migration to remove Member.rw 2021-07-22 17:40:08 +03:00
Pēteris Caune
cb799dbd29
Remove the Member.rw field (superseded by Member.role) 2021-07-22 17:28:38 +03:00
Pēteris Caune
936a5213f8
Switch from Member.rw to Member.role as the source of truth 2021-07-22 17:16:52 +03:00
Pēteris Caune
d19cb8c681
Add a data migration to populate Member.role 2021-07-22 16:28:02 +03:00
Pēteris Caune
5230dbb425
Add Member.role field 2021-07-22 16:13:41 +03:00
Pēteris Caune
e46000ecdf
Add admin action to log in as any user 2021-07-20 11:16:12 +03:00
Pēteris Caune
79dc4d2e7a
Fix html structure in the signup dialog 2021-07-16 16:45:26 +03:00
Pēteris Caune
02cdbb9222
Fix page structure, update copy 2021-07-16 16:36:32 +03:00
Pēteris Caune
94c5ea3e13
Fix page structure 2021-07-16 16:34:15 +03:00
Pēteris Caune
2382bf6722
Add SITE_LOGO_URL setting
Fixes: #323
2021-07-16 15:30:34 +03:00
Pēteris Caune
dd88924660
Fix dark mode styling issues in Cron Syntax Cheatsheet 2021-07-16 12:25:16 +03:00
Pēteris Caune
b75b062559
Remove unsigned token support in hc.front.views.unsubscribe_email 2021-07-16 12:01:44 +03:00
Pēteris Caune
e186d039fc
Upgrade to psycopg2==2.9.1 and requests==2.26.0 2021-07-16 11:24:45 +03:00
Pēteris Caune
2271a4dbb0
Remove glyphicons (unused) 2021-07-07 15:23:35 +03:00
Pēteris Caune
99bb71c920
Use multicolor channel icons for better appearance in the dark mode 2021-07-07 15:23:02 +03:00
Pēteris Caune
5c54afadb5
Fix contrast in "Add Integration" pages, step circles 2021-07-05 18:03:37 +03:00
Pēteris Caune
c94e39c9d3
Add CSS to invert Matrix and Mattermost logos in dark mode 2021-07-05 17:55:13 +03:00
Pēteris Caune
92a9910092
Improve logos for the dark mode 2021-07-05 17:31:10 +03:00
Pēteris Caune
0e7252d8fa
Update Discord logo 2021-07-05 17:16:29 +03:00
Pēteris Caune
5a4c06ffae
Update CHANGELOG for v1.21.0 release 2021-07-02 16:52:24 +03:00
Pēteris Caune
92ef81c0a5
Add workflow_dispatch for testing 2021-07-02 16:47:06 +03:00
Pēteris Caune
83eb10b99e
Rename secret names in publish_docker_image.yml 2021-07-02 16:40:20 +03:00
Pēteris Caune
ec56ceae8f
Merge branch 'master' of https://github.com/mirobertod/healthchecks into mirobertod-master 2021-07-02 16:38:30 +03:00
Pēteris Caune
d243f502d3
Fix off-by-one-month error in monthly reports, downtime columns
Fixes: #539
2021-07-02 15:22:51 +03:00
Roberto Dedoro
760c3757f3
update docker image name 2021-07-02 13:23:38 +08:00
Roberto Dedoro
c2d8166e74
Create publish_docker_image.yml 2021-07-02 13:17:36 +08:00
Pēteris Caune
61a8a8de26
Remove Profile.reports_allowed (obsolete)
It is obsoleted by Profile.reports
2021-06-29 14:38:06 +03:00
swoga
b70e2c9a25 feat: treat failure before success 2021-06-29 14:05:56 +03:00
Pēteris Caune
8a154cbaf5
Expose Credentials model in Django admin
This is to help troubleshoot 2FA issues without
running manual SQL queries.
2021-06-29 10:46:08 +03:00
Pēteris Caune
52e1d420b5
Add nicoandrade/healthchecks-front to resources
Fixes: #536
2021-06-28 13:41:57 +03:00
Pēteris Caune
1c02d1ff87
Fix dark mode bugs 2021-06-21 15:57:50 +03:00
Pēteris Caune
c75d17618c
Fix renaming mistake 2021-06-21 15:19:03 +03:00
Pēteris Caune
93a881d0ba
Move color overrides to variables.less where possible 2021-06-21 15:12:06 +03:00
Pēteris Caune
fdfd988c5c
Fix dark mode bugs 2021-06-21 12:14:03 +03:00
Pēteris Caune
6e01af3327
Fix dark mode bugs 2021-06-21 11:42:18 +03:00
Pēteris Caune
2d20f439dd
Remove PagerDuty Connect
PagerDuty Connect is deprecated and will be discontinued.
It is replaced by PagerDuty Simple Install Flow (see
README for setup instructions).
2021-06-21 10:44:21 +03:00
Pēteris Caune
059a855b3f
Fix more contrast issues 2021-06-18 17:07:27 +03:00
Pēteris Caune
b185a28676
Fix contrast issues 2021-06-18 15:28:35 +03:00
Pēteris Caune
6c10980889
Add Account Settings > Appearance page 2021-06-18 13:51:07 +03:00
Pēteris Caune
13334d2ab0
Implement explicit light/dark mode selection (WIP) 2021-06-18 12:27:43 +03:00
Pēteris Caune
4f72c9e204
Fix dark mode CSS for tabs take 2 2021-06-16 16:00:24 +03:00
Pēteris Caune
dd104ff672
Fix dark mode CSS for tabs 2021-06-16 15:57:33 +03:00
Pēteris Caune
c5229d6505
Add CSS for dark mode 2021-06-16 15:23:34 +03:00
Pēteris Caune
fd7ab5e767
Implement PagerDuty Simple Install Flow 2021-06-16 14:18:32 +03:00
Pēteris Caune
2cd2bfed6f
Update Django to 3.2.4 2021-06-03 08:03:10 +03:00
Pēteris Caune
a0cd2c63e9
Update report templates for weekly reports 2021-05-26 09:48:23 +03:00
Pēteris Caune
8ce09ab9e5
Widen report time window to 9AM - 11AM 2021-05-24 15:17:27 +03:00
Pēteris Caune
548b2ac33c
Update the signup form to collect browser's timezone 2021-05-24 14:38:12 +03:00
Pēteris Caune
6094bca241
Improve wording 2021-05-24 14:13:43 +03:00
Pēteris Caune
fa5dd8b45a
Add mitigation for bad tz values 2021-05-24 14:04:05 +03:00
Pēteris Caune
df44ee58c0
Add an option for weekly reports (in addition to monthly) 2021-05-24 13:44:34 +03:00
Pēteris Caune
03a538c5e2
Add Profile.reports field
This is in preparation of adding an option for weekly
reports (#407)
2021-05-24 11:20:28 +03:00
Pēteris Caune
5ca7262164
Update Zulip icon in the icon font 2021-05-24 08:59:28 +03:00
Puneeth Chaganti
82dc6844ae Update Zulip logo 2021-05-24 08:21:45 +03:00
Pēteris Caune
ac83bf8896 Fix attribution in .po header 2021-05-21 14:19:42 +03:00
Pēteris Caune
32ca8b3420 Move under LC_MESSAGES and fix template syntax 2021-05-21 14:19:42 +03:00
Richard Lippmann
3e207e538a German translation of django.po and django.mo 2021-05-21 14:19:42 +03:00
Pēteris Caune
e91441d814
Add fallback for legacy sms values 2021-05-21 13:05:37 +03:00
Pēteris Caune
855d188981
Add support for "... is UP" SMS notifications
Fixes: #512
2021-05-21 12:57:23 +03:00
Pēteris Caune
e090aa5403
Improve the handling of unknown email addresses in the Sign In form 2021-05-12 13:49:56 +03:00
Pēteris Caune
94416c90dc
Update pytz to 2021.1 2021-05-12 13:00:10 +03:00
Pēteris Caune
ae4487b6c3
Update to Django 3.2.2 2021-05-06 11:07:51 +03:00
Pēteris Caune
64f2e86051
Increase "Success / Failure Keywords" field lengths to 200 2021-05-06 11:00:36 +03:00
Pēteris Caune
aa71629ffc
Add a note in README about per-user ping log limit 2021-05-05 20:21:59 +03:00
Pēteris Caune
599f481d58
Set maxlength on input fields in the Filtering Rules dialog 2021-05-05 17:37:47 +03:00
Pēteris Caune
a36c326e32
Update Django version to 3.2.1 2021-05-05 10:30:09 +03:00
Pēteris Caune
e2b96d9bd8
Update CHANGELOG for v1.20.0 release 2021-04-22 13:03:07 +03:00
Pēteris Caune
6ed983cdd5
Improve copy in "Profile" > "Email and Password" section
When an account has a password, replace "Set Password"
button's label with "Change Password"
2021-04-22 10:31:35 +03:00
Pēteris Caune
6d2c67338c
Improve the ALLOWED_HOSTS description
Fixes: #499
2021-04-21 17:59:10 +03:00
Pēteris Caune
6c8b6a2a19
Remove functools.cached_property usage
Cannot use functools.cached_property, as it was added in Py 3.8,
but we support 3.6+
2021-04-14 16:29:28 +03:00
Pēteris Caune
738a648407
Improve project sorting in the "My Projects" page
Primary sort key: projects with overall_status=down go first
Secondary sort key: project's name
2021-04-14 16:18:43 +03:00
Pēteris Caune
4587b45cab
Add more tests for hc.api.views.create_check 2021-04-14 12:21:58 +03:00
Pēteris Caune
2831e5d7c1
Add a test case for filtering flips by timestamp 2021-04-14 12:00:32 +03:00
Pēteris Caune
742af7bfd8
Remove unused return statement 2021-04-14 11:54:43 +03:00
Pēteris Caune
78652b5659
Upgrade Django version to 3.2 2021-04-07 11:39:11 +03:00
Pēteris Caune
67d11e8d40
Fix the month boundary calculation in monthly reports
Fixes: #497
2021-04-02 13:49:55 +03:00
Pēteris Caune
aa7ef5e9bb
Upgrade croniter to 1.0.8 2021-03-15 14:13:27 +02:00
Pēteris Caune
68b1d5bb8b
Fix the "Email Reports" screen to clear Profile.next_nag_date 2021-03-15 13:06:57 +02:00
Pēteris Caune
1d6b75d5dc
Move Profile *model* tests to test_profile_model 2021-03-15 12:56:07 +02:00
Pēteris Caune
05db43f95d
Fix the pause action to clear Profile.next_nag_date if all checks up 2021-03-15 12:52:35 +02:00
Pēteris Caune
7ba5fcbb71
Fix sendalerts to clear Profile.next_nag_date if all checks up
Profile.next_nag_date tracks when the next hourly/daily reminder
should be sent. Normally, sendalerts sets this field when
a check goes down, and sendreports clears it out whenever
it is about to send a reminder but realizes all checks are up.

The problem: sendalerts can set next_nag_date to a non-null
value, but it does not clear it out when all checks are up.
This can result in a hourly/daily reminder being sent out
at the wrong time. Specific example, assuming hourly reminders:

13:00: Check A goes down. next_nag_date gets set to 14:00.
13:05: Check A goes up. next_nag_date remains set to 14:00.
13:55: Check B goes down. next_nag_date remains set to 14:00.
14:00: Healthchecks sends a hourly reminder, just 5 minutes
       after Check B going down. It should have sent the reminder
       at 13:55 + 1 hour = 14:55

The fix: sendalerts can now both set and clear the next_nag_date
field. The main changes are in Project.update_next_nag_dates()
and in Profile.update_next_nag_date(). With the fix:

13:00: Check A goes down. next_nag_date gets set to 14:00.
13:05: Check A goes up. next_nag_date gets set to null.
13:55: Check B goes down. next_nag_date gets set to 14:55.
14:55: Healthchecks sends a hourly reminder.
2021-03-15 12:34:39 +02:00
Pēteris Caune
502ff7567e
Fix md formatting in the REMOTE_USER_HEADER section 2021-03-11 14:48:07 +02:00
Mathias Chevalier
a101b1de91 [DOCKER] Fix failing build on armhf, due to cryptography lib trying to build rust 2021-03-11 14:08:36 +02:00
Pēteris Caune
57336187a7
Fix HTML email preview in the checks list 2021-03-10 12:29:20 +02:00
Pēteris Caune
ad886fe157 Add more content in CONTRIBUTING 2021-03-08 17:16:24 +02:00
Ivan Smirnov
0c58a9b9ee Add CONTRIBUTING.md
- Add requirements for debian hosts
- Add section that explains how to add new docs.
2021-03-08 17:16:24 +02:00
Pēteris Caune
e2576607f5
Remove the width attribute to preserve logo aspect ratio
See also: #483
2021-03-08 15:04:43 +02:00
cocide
e66725e23f Added formatting on ping.body in emails
Not all email clients are formatting the `ping.body` contents uniformly. Even using different applications from the same email provider results in a different display of the `ping.body` contents. There are two basic issues:
* Not all email clients are honoring the fixed-width font that should be used inside `<pre>` tags. Using fixed-width font is listed in the definition on https://www.w3schools.com/tags/tag_pre.asp
* Not all email clients are displaying the text with a 1em line height. This was a recent change to the healthchecks WebUI in 9fd9c8e4efd0c87c015215ae56eada88a7d07b6b but is not part of the definition of the `<pre>` tag. I'd like to add this to the emails to make Healthchecks more uniform between the website and the email notification.

Gmail Webmail:
- [x] Is using fixed-width font
- [ ] Line height is set by the webmail client to 18px
Gmail Android App:
- [ ] Text is not fixed-width
- [ ] Line height has extra padding

ProtonMail Webmail:
- [x] Is using fixed-width font
- [x] Line height is correct
ProtonMail Android:
- [ ] Text is not fixed width
- [ ] Line height has extra padding

The testing I performed is not extensive, but it does show how multiple clients are displaying the contents differently. To make the display of the `ping.body` more uniform I'd like to add a bit of formatting information to the `<pre>` tag.
2021-03-08 12:38:48 +02:00
cocide
9fd9c8e4ef fix line height on request body
The line height in the ping request body is being set by the bootstrap.css body tag to 1.42857143. The additional line height makes request bodies with unicode characters have spacing between lines that shouldn't be there.
2021-03-04 17:49:55 +02:00
Pēteris Caune
2bfea987e9
Replace details_url with cloaked_url in email and chat notifications 2021-03-04 16:55:05 +02:00
Pēteris Caune
5321f772fe
Add a link to check's details page in Slack notifications
Fixes: #486
2021-03-04 15:51:35 +02:00
Laura Hausmann
448721e916 Add colored emojis to telegram messages
so you can see whether it's up or down at first glance
2021-03-04 12:21:28 +02:00
Pēteris Caune
1d62176f34
Remove non-standard "zoom: 1" CSS property 2021-02-27 09:04:49 +02:00
Pēteris Caune
d4fc314696
Set iframe's charset to utf8 2021-02-26 16:36:45 +02:00
Pēteris Caune
46bc7d8306
Improve HTML email display in the "Ping Details" dialog 2021-02-26 16:25:39 +02:00
Pēteris Caune
2a63d24812
Add a "Subject" field in the "Ping Details" dialog 2021-02-26 11:19:44 +02:00
Pēteris Caune
1bc89f0d5d
Implement email body decoding in the "Ping Details" dialog 2021-02-23 17:34:33 +02:00
Pēteris Caune
18b39a5a79
Bump django to 3.1.7 2021-02-22 10:13:49 +02:00
Pēteris Caune
44a677f327
Fix hc.api.views.notification_status to always return 200
If the notification does not exist, or is more than a hour
old, return HTTP 200 (instead of 400 or 404) so the other
party doesn't retry over and over again.
2021-02-09 14:25:26 +02:00
Pēteris Caune
1e84cac37d
Relax cron expression validation
Accept all expressions that croniter accepts.
If cron-descriptor throws an exception, don't show the
description to the user.
2021-02-09 11:34:53 +02:00
Pēteris Caune
f06616a934
Add Python 3.9 to the testing matrix 2021-02-05 09:33:20 +02:00
Pēteris Caune
6cd3f0e35a
Upgrade psycopg2 2.8.4 -> 2.8.6 2021-02-05 09:22:30 +02:00
Pēteris Caune
68e19c938e
Upgrade requests version 2.23.0 -> 2.25.1 2021-02-05 08:51:19 +02:00
Pēteris Caune
438ae0264e
Pin fido2 version 2021-02-05 08:47:37 +02:00
Pēteris Caune
474d782869
Rename VictorOps -> Splunk On-Call 2021-02-03 16:23:15 +02:00
Pēteris Caune
c1f433bb71
Rename VictorOps -> Splunk On-Call 2021-02-03 16:09:24 +02:00
Pēteris Caune
5979204691
Fix downtime summary to handle months when the check didn't exist
Fixes: #472
2021-02-03 14:27:06 +02:00
Pēteris Caune
0a0b48a3fe
Update CHANGELOG for v1.19.0 release 2021-02-03 10:57:39 +02:00
Pēteris Caune
b788c7e4f5
Add Healthchecks version in the site footer 2021-02-03 10:56:50 +02:00
Pēteris Caune
67560c96e1
Change icon CSS class prefix to work around Fanboy's filter list
Problem: if you use uBlock Origin, and enable the
"Fanboy's Social" filter list, Healthchecks does not show
Telegram or WhatsApp icons. This is because the filter list
contains "##.icon-telegram" and "##.icon-whatsapp" entries.

This commit changes the CSS class prefix to "ic-". So we're
now using icon classes like "ic-telegram" and "ic-whatsapp".

As a bonus, we save 2 bytes in HTML per displayed icon :-)
2021-02-03 10:44:35 +02:00
Pēteris Caune
dc9fcfa0ab
Fix a grid misalignment in the welcome page 2021-02-03 10:05:06 +02:00
Pēteris Caune
65ace8238a
Add the ZULIP_ENABLED setting 2021-02-03 09:11:32 +02:00
Pēteris Caune
e2c90c05b8
Add the VICTOROPS_ENABLED setting 2021-02-03 09:00:28 +02:00
Pēteris Caune
205f1ccce6
Upgrade croniter to 1.0.6 2021-02-02 08:50:25 +02:00
snyk-bot
a5ea8a03c6 fix: requirements.txt to reduce vulnerabilities
The following vulnerabilities are fixed by pinning transitive dependencies:
- https://snyk.io/vuln/SNYK-PYTHON-DJANGO-1066259
2021-02-02 08:45:14 +02:00
Pēteris Caune
238d0b8ff1
Upgrade croniter to 1.0.5 2021-01-29 16:29:50 +02:00
Pēteris Caune
8811640d45
Add the SPIKE_ENABLED setting 2021-01-29 15:21:38 +02:00
Pēteris Caune
725be65bdd
Add the PROMETHEUS_ENABLED setting 2021-01-29 15:05:42 +02:00
Pēteris Caune
419d96da7a
Add the PAGERTREE_ENABLED setting 2021-01-29 14:21:02 +02:00
Pēteris Caune
28150e85fa
Add the PD_ENABLED setting 2021-01-29 14:06:40 +02:00
Pēteris Caune
8d5890d883
Add the OPSGENIE_ENABLED setting, rename OpsGenie -> Opsgenie 2021-01-29 13:47:13 +02:00
Pēteris Caune
5f31b8b873
Add the MSTEAMS_ENABLED setting 2021-01-29 13:20:44 +02:00
Pēteris Caune
6c3debaf11
Add the MATTERMOST_ENABLED setting 2021-01-29 12:36:47 +02:00
Pēteris Caune
52435a9a0c
Add the SLACK_ENABLED setting 2021-01-29 11:59:33 +02:00
Pēteris Caune
67ff8a9bee
Add the WEBHOOKS_ENABLED setting 2021-01-29 11:16:11 +02:00
Pēteris Caune
45078e6566
Set the SECRET_KEY default value back to "---"
Previously, I had changed the default value to "", to force
users to set the SECRET_KEY value (the app refuses to start
if SECRET_KEY is empty).

The problem with that is, out of the box, with the default
configuration, the tests also don't run and complain about the
empty SECRET_KEY.

So, a compromise: revert back to the default value "---".
At runtime, if SECRET_KEY has the default value, show a  warning
at the top of every page.
2021-01-28 15:38:14 +02:00
Pēteris Caune
dc39831aef
Reorder integrations in settings.py in A-Z order 2021-01-28 15:08:53 +02:00
Pēteris Caune
59ebcb963f
Remove the "Configuration" section, link to docs instead 2021-01-28 14:32:23 +02:00
Pēteris Caune
4e480cac57
Update instructions in docker/README.md 2021-01-28 14:18:06 +02:00
Pēteris Caune
c2bb4b31b5
Add rate limiting for Pushover notifications 2021-01-28 14:07:39 +02:00
Pēteris Caune
ae976a38b6
Fix a crash when adding an integration for an empty Trello account 2021-01-28 12:57:08 +02:00
Pēteris Caune
b9997137a6
Bump croniter version to 1.0.2 2021-01-27 09:46:33 +02:00
Pēteris Caune
98b1e13aa1
Update the Docker Compose sample to use an .env file 2021-01-26 14:00:54 +02:00
Pēteris Caune
168f8b0bc6
Fix alphabetic order 2021-01-26 14:00:23 +02:00
msansen1
5ee0ef6381
Adding french and gitignore file
revert gitignore file

fix remove a space

fix french locale

Add .mo for the French translations
2021-01-26 11:30:26 +02:00
Pēteris Caune
1419da460e
Add id attributes to headings in the "Server Configuration" page 2021-01-23 14:30:38 +02:00
Pēteris Caune
35e6d41793
Add README 2021-01-21 17:43:28 +02:00
Pēteris Caune
a763fa1de3
Fix DEFAULT_FROM_EMAIL 2021-01-21 17:36:11 +02:00
Pēteris Caune
98439623c5
Add experimental Dockerfile and docker-compose.yml 2021-01-21 17:32:58 +02:00
Pēteris Caune
601d8fac94
Remove the warning about a missing local_settings.py 2021-01-21 17:32:25 +02:00
Pēteris Caune
376d80afd4
Add more content from README 2021-01-21 13:57:55 +02:00
Pēteris Caune
7e6afba8bd
Fix CI: set SECRET_KEY 2021-01-21 11:38:52 +02:00
Pēteris Caune
b7c769fc0e
Add a section in Docs about running self-hosted instances
Fixes: #467
2021-01-21 11:35:09 +02:00
Pēteris Caune
fbefcbc0ed
Update apprise tests to skip if apprise is not installed 2021-01-19 13:57:55 +02:00
Pēteris Caune
94abe0fbb5
Update intro and dev setup steps in README 2021-01-19 13:19:46 +02:00
Shea Polansky
5540fc2c6d Update README.txt to include more dev setup steps 2021-01-19 12:36:08 +02:00
Pēteris Caune
d45dc2f6a3
Change Zulip onboarding, ask for the zuliprc file
Fixes: #202
2021-01-19 11:04:38 +02:00
Pēteris Caune
9a0888aacd
Update sendalerts to log per-notification send times
To send notifications, sendalerts calls Flip.send_alerts().
I updated Flip.send_alerts() to be a generator, and to yield
a (channel, error, send_time_in_seconds) triple per sent
notification.
2021-01-15 15:15:00 +02:00
Pēteris Caune
3b6afae140
Specify timeout in the DBus calls 2021-01-14 09:59:06 +02:00
Pēteris Caune
1e46cd6e93
Tweak coveralls configuration
coveralls.io is throwing 422 and breaking CI,
this may or may not help.

Related: https://github.com/TheKevJames/coveralls-python/issues/252
2021-01-13 15:43:18 +02:00
Pēteris Caune
d7c7ae6531
Fix tests 2021-01-13 12:13:14 +02:00
Pēteris Caune
ce7e32ac03
Fix tests 2021-01-13 11:57:19 +02:00
Pēteris Caune
74ed15e0aa
Update the signal integration to use DBus
The initial implementation was just calling signal-cli directly
using `subprocess.run`.

Going with DBus makes it easier to shield signal-cli from the
rest of the system. It also makes sure the signal-cli daemon is
running in the background and receiving messages. This is important
when a recipient does the "Reset secure connection" from the app. We
must receive their new keys, otherwise our future messages will
appear as "bad encrypted message" for them.
2021-01-13 11:52:42 +02:00
Pēteris Caune
a80b831eea
Add rate-limiting for Signal messages 2021-01-11 15:07:34 +02:00
Pēteris Caune
d4aac691ce
Increase the timeout for sending Signal messages 2021-01-11 12:56:53 +02:00
Pēteris Caune
ee37d305ef
Tighten Telegram rate limit to 6 messages / minute
With the previous 10 minutes / minute limit we were still hitting
Telegram API rate limit (the 429, "Too Many Requests" response)
from time to time.

Therefore, tighten the limit a bit on our side.
2021-01-11 10:54:46 +02:00
Pēteris Caune
f607ee67d5
Allow searching in the error field in Notifications admin 2021-01-11 10:08:36 +02:00
Pēteris Caune
0aeef7d06e
Fix unwanted HTML escaping in SMS and WhatsApp notifications 2021-01-10 18:29:38 +02:00
Pēteris Caune
55a22e5043
Split AddSmsForm into PhoneNumberForm and PhoneUpDownForm
The PhoneNumberForm is used in "Add SMS" and "Add Phone Call" pages.
The PhoneUpDownForm is a subclass of PhoneNumberForm and
adds "up" and "down" boolean fields. It is used in "Add Signal"
and "Add WhatsApp" pages.
2021-01-10 15:52:33 +02:00
Pēteris Caune
847a610af9
Sort hc-add-* routes 2021-01-09 16:52:48 +02:00
Pēteris Caune
cd99af14ba
Add Signal integration
Fixes: #428
2021-01-09 11:58:18 +02:00
Pēteris Caune
959df1ffaa
Upgrade Django to 3.1.5 2021-01-04 11:02:19 +02:00
Pēteris Caune
17a404f04b
Fix email template to always show the current year in the footer 2021-01-01 22:43:00 +02:00
Pēteris Caune
599f35e4f0
Improve the crontab snippet in the "Check Details" page
Fixes: #465
2020-12-30 13:49:33 +02:00
Pēteris Caune
bf3df906f7
Tweak email CSS for nicer display in dark mode 2020-12-29 17:50:26 +02:00
Pēteris Caune
54081208c5
Add doctype declaration in the alert email template
Need it to escape quirks mode in email clients.
2020-12-29 16:08:23 +02:00
Pēteris Caune
efc44fd47c
Update report template to use same font size for all check names
Fixes: #347
2020-12-29 15:14:37 +02:00
Pēteris Caune
ca3d1d3a3b
Add the "Last Ping Type" field in the email notification template 2020-12-28 17:34:58 +02:00
Pēteris Caune
26a7918b5b
Bump pytz version 2020.1 -> 2020.5 2020-12-28 14:23:48 +02:00
Pēteris Caune
02b5ec3657
Rename "Signalling Failures" -> "Signaling Failures" 2020-12-28 14:12:07 +02:00
Pēteris Caune
70519fcd89
Fix spelling, grammar, style mistakes 2020-12-28 14:06:54 +02:00
Pēteris Caune
8fa0d04830
Exclude Bootstrap's popovers
Not using them anywhere on the site currently, so commented them out
in bootstrap.less and regenerated bootstrap.css.
2020-12-28 12:34:02 +02:00
Tim Gates
1f641962d2
docs: fix simple typo, libary -> library (#464)
Fix simple typo in docs, libary -> library

There is a small typo in templates/docs/python.md.

Should read `library` rather than `libary`.
2020-12-28 12:30:58 +02:00
Pēteris Caune
ce0f84a112
Fix styling 2020-12-26 18:19:55 +02:00
Pēteris Caune
8fe8e0f605
Update alert email template: more information, less styling
Fixes: #348
2020-12-26 18:11:36 +02:00
Pēteris Caune
c3b6d40012
Fix selectize initialization in the Details page
Fixes: #462
2020-12-26 14:51:30 +02:00
Pēteris Caune
823b3dbc7b
Fix tests 2020-12-16 14:11:36 +02:00
Pēteris Caune
77a5f11cf9
Update OpsGenie instructions
Fixes: #450
2020-12-16 14:09:48 +02:00
Pēteris Caune
0f1abd3498
Add tighter parameter checks in hc.front.views.serve_doc 2020-12-14 19:08:36 +02:00
Pēteris Caune
b8f1bdaf96
Update changelog for release 2020-12-09 16:03:49 +02:00
Pēteris Caune
dfd159ab18
Add a "Lost password?" link with instructions in the Sign In page 2020-12-09 15:38:19 +02:00
Shea Polansky
54a95a0ee2
Add http header auth (#457)
* Add HTTP header authentiation backend/middleware

* Add docs for remote header auth

* Improve docs on external auth

* Add warning for unknown  REMOTE_USER_HEADER_TYPE

* Move active check for header auth to middleware
Add extra header type sanity check to the backend

* Add test cases for remote header login

* Improve header-based authentication

- remove the 'ID' mode
- add CustomHeaderBackend to AUTHENTICATION_BACKENDS conditionally
- rewrite CustomHeaderBackend and CustomHeaderMiddleware to
use less inherited code
- add more test cases

Co-authored-by: Pēteris Caune <cuu508@gmail.com>
2020-12-09 11:25:56 +02:00
Pēteris Caune
5e3e371661
Set up CodeQL analysis 2020-12-09 11:09:35 +02:00
Pēteris Caune
70ef9c1904
Remove unused CSS 2020-12-08 11:25:09 +02:00
Pēteris Caune
ea6d04d061
Bump Django version to 3.1.4 2020-12-07 11:11:51 +02:00
Pēteris Caune
5d650f07fb
Fix db field overflow when copying a check with a long name 2020-12-03 13:01:53 +02:00
Pēteris Caune
9623e3eacb
Update 3rd party resources
Move terraform-provider-healthchecksio to the "API Wrappers"
category, which is more appropriate than "Tools for Self-Hosting".
2020-12-01 15:05:36 +02:00
Pēteris Caune
ec40082550
Update 3rd party resources
Move terraform-provider-healthchecksio to the "API Wrappers"
category, which is more appropriate than "Tools for Self-Hosting".
2020-12-01 15:04:30 +02:00
Pēteris Caune
617bd92434
Add Ping.exitstatus field, store received exit status values in db
Fixes #455
2020-11-29 12:12:44 +02:00
Pēteris Caune
524d1a7375
Implement badge mode (up/down vs up/late/down) selector
Fixes #282
2020-11-27 12:57:25 +02:00
Pēteris Caune
dd45c888a7
Rearrange resources, add msfjarvis/healthchecks-rs 2020-11-22 20:02:35 +02:00
Pēteris Caune
b9abcbcdee
Update build badge, remove Travis configuration 2020-11-21 00:43:14 +02:00
Pēteris Caune
62fcd30ce8
Add configuration for running tests with Github Actions (#453) 2020-11-21 00:31:15 +02:00
Pēteris Caune
eed7ef36d1
Improve text instructions 2020-11-19 17:35:21 +02:00
Pēteris Caune
0b4251bdee
Add logic to handle exceptions thrown by the fido2 library 2020-11-19 16:53:58 +02:00
Pēteris Caune
c8d387aee4
Improve text instructions 2020-11-19 16:35:44 +02:00
Pēteris Caune
3cfc31610a
Add extra security checks in the login_webauthn view 2020-11-19 16:21:31 +02:00
Pēteris Caune
8448f882cf
Add notes about adding a second key, and removing the last key 2020-11-19 15:05:08 +02:00
Pēteris Caune
568a287850
Fix WebAuthn registration to use random bytes for user handle
User handle is used in a username-less authentication, to map a
credential received from browser with an user account in the
database. Since we only use security keys as a second factor,
the user handle is not of much use to us.

The user handle:
 - must not be blank,
 - must not be a constant value,
 - must not contain personally identifiable information.

So we use random bytes, and don't store them on our end.
2020-11-19 13:59:23 +02:00
Pēteris Caune
8dbf9e02af
Fix capitalization, Webauthn -> WebAuthn 2020-11-19 13:01:26 +02:00
Pēteris Caune
7124383a53
Add checks for RP_ID, add a 2FA section in README 2020-11-19 12:54:00 +02:00
Pēteris Caune
9401bc3987
Update the "Close Account" function to use confirmation codes 2020-11-16 16:22:25 +02:00
Pēteris Caune
48750ee668
Update "Change Password" to show messages in panel's footer 2020-11-16 15:45:25 +02:00
Pēteris Caune
fb79948759
Update the "Change Email" function to use confirmation codes 2020-11-16 15:33:29 +02:00
Pēteris Caune
ed6b15bfa9
Update the "Set Password" function to use confirmation codes 2020-11-16 14:53:50 +02:00
Pēteris Caune
1ca4caa3a8
Update the set_password view to use update_session_auth_hash
Changing user's password logs themselves out. To avoid that,
we were logging the user back in right after changing the password.

I recently discovered update_session_auth_hash, which seems to
be the proper way to do this.

Docs: https://docs.djangoproject.com/en/3.1/topics/auth/default/#session-invalidation-on-password-change
2020-11-16 14:29:52 +02:00
Pēteris Caune
adb7702f39
Rename login_tfa to login_webauthn 2020-11-16 14:16:06 +02:00
Pēteris Caune
7639f0dd69
Add test cases for the login_tfa view 2020-11-16 14:01:04 +02:00
Pēteris Caune
d0f327b213
Add Base64Field field (base64-encoded binary data) 2020-11-16 13:10:38 +02:00
Pēteris Caune
839c309cf7
Refactor for testability, add more test cases 2020-11-16 12:52:26 +02:00
Pēteris Caune
155a1f132b
Simplify super() calls in tests 2020-11-16 11:20:01 +02:00
Pēteris Caune
155226d82a
Add tests for sudo mode 2020-11-16 10:58:38 +02:00
Pēteris Caune
ecf964ea3b
Remove a verify_origin workaround 2020-11-15 21:49:25 +02:00
Pēteris Caune
9f58ebfd3e
Hook up a 2FA check after a password or email link authentication 2020-11-15 21:39:49 +02:00
Pēteris Caune
64be87137b
Add a two-factor authentication form (WIP) 2020-11-14 12:54:26 +02:00
Pēteris Caune
2ac0f87560
Implement a "Remove Security Key" feature 2020-11-14 11:45:09 +02:00
Pēteris Caune
42497fe91a
Add rate limiting to the sudo code form 2020-11-13 22:04:19 +02:00
Pēteris Caune
2c3286c280
Improve the "add security key" UX, require sudo mode 2020-11-13 16:23:28 +02:00
Pēteris Caune
e3aedd3b03
Add require_sudo_mode decorator
Planning to use it for sensitive operations (add/remove security keys),
change email, change password, close account.

The decorator sends a six-digit confirmation code to user's email
and renders a form for entering it back. If the user enters the
correct code, the decorators sets a sudo=active marker in
user's session, valid for 30 minutes.
2020-11-13 11:08:06 +02:00
Pēteris Caune
03ea725612
Add Credential.created field 2020-11-12 18:03:12 +02:00
Pēteris Caune
53688f1d87
Add error handling on the client side, use Django form API 2020-11-12 17:08:23 +02:00
Pēteris Caune
1eaa216d3a
Add experimental code for registering Webauthn credentials 2020-11-12 16:15:07 +02:00
Pēteris Caune
cdd2e98bd0
Remove USE_I18N and USE_L10N from settings
They have the default values and so are redundant.
2020-11-06 18:51:30 +02:00
Pēteris Caune
816c158744
Fix code formatting in the Notification model 2020-11-06 18:50:23 +02:00
Pēteris Caune
d5502c50ca
Add retries to the the email sending logic
When sending email using Django's default email
backend (SMTP), and if there is a network issue, the backend
can throw SMTPServerDisconnected.

This commit adds a retry logic which retries sending the
email two times when SMTPServerDisconnected is thrown.
2020-10-30 14:18:38 +02:00
Pēteris Caune
0b685e8b5a
Disable retries when testing webhook integration
Normally, when a webhook call fails (timeout, connection
error, non-2xx response), the HTTP request is retried up to two
times (so up to 3 times total). This is useful when sending
actual notifications, in case the webhook target has a temporary
glitch.

When interactively testing a webhook integration
("Send Test Notification" in the
"Integrations" page), we would prefer to see any errors ASAP
on the screen instead of retrying and so possibly swallowing them.

One specific use case is webhook targets that take long time to
generate a response. "Send Test Notification" is synchronous,
meaning that the user could be stuck for
5 x 3 = 15 seconds waiting for the  test HTTP request to time out
three times.
2020-10-30 12:36:17 +02:00
Pēteris Caune
f7e004b2ea
Improve phone number sanitization: remove spaces and hyphens 2020-10-30 11:32:09 +02:00
Pēteris Caune
81e59ac553
Add support for script's exit status in ping URLs
Fixes: #429
2020-10-28 14:28:32 +02:00
Pēteris Caune
6f56ed7f92
Reduce the number of SQL queries used in the "Get Checks" API call 2020-10-27 16:19:57 +02:00
Pēteris Caune
078577cbb7
Update the read-only dashboard's CSS for better mobile support
Fixes: #442
2020-10-27 15:27:44 +02:00
Pēteris Caune
a37e83aca8
Update AddSmsForm to remove any invisible unicode characers 2020-10-20 15:53:27 +03:00
Pēteris Caune
7534f1856f
Add testcases for setting channels in the "Create Check" API call 2020-10-14 18:12:35 +03:00
Pēteris Caune
7e56156d32
Optimize the "Update Check" API call
In the "Update Check" API call, if no fields have changed,
don't save the changes to the database.
2020-10-14 18:03:13 +03:00
Pēteris Caune
0e77064c44
Update API to allow specifying channels by names
Fixes: #440
2020-10-14 15:37:04 +03:00
Pēteris Caune
20008a1d7e
Fix wording 2020-10-14 13:15:11 +03:00
Pēteris Caune
71d7b46379
Add a tooltip to the 'confirmation link' label
Fixes: #436
2020-10-14 13:13:22 +03:00
Pēteris Caune
a10215ce65
Update CHANGELOG for 1.17.0 release 2020-10-14 12:39:42 +03:00
Pēteris Caune
463ec8c988
Set the "title" and "summary" fields in MS Teams notifications
Fixes: #435
2020-10-06 16:43:56 +03:00
Pēteris Caune
63beeb05a1
Add missing slashes 2020-10-03 21:00:35 +03:00
Pēteris Caune
a13b44284e
Django 3.1.2 2020-10-01 09:16:59 +03:00
Pēteris Caune
1967c712ca
Add Matrix setup instructions in README cc: #427 2020-09-21 15:04:57 +03:00
Pēteris Caune
fd8da1b642
Update screenshots in Matrix setup instructions 2020-09-21 14:47:22 +03:00
Pēteris Caune
05c81e0a41
Escape markdown in MS Teams notifications. cc: #426 2020-09-11 11:49:46 +03:00
Pēteris Caune
b64c8d1cb8
API support for setting the allowed HTTP methods for making ping requests 2020-09-10 10:29:44 +03:00
Pēteris Caune
c13f65e118
Grammar and style fixes. 2020-09-09 17:53:24 +03:00
Pēteris Caune
b4729cdb57
Grammar and style fixes. 2020-09-09 12:21:18 +03:00
Pēteris Caune
e63aa9fe8d
Grammar and style fixes, updated illustration. 2020-09-09 11:33:50 +03:00
Pēteris Caune
66a1a108bf
When decoding inbound emails, decode encoded headers. Fixes #420 2020-09-08 12:06:32 +03:00
Pēteris Caune
bd98174d4c
Fix missing Resume button. Fixes #421 2020-09-04 13:17:54 +03:00
Pēteris Caune
0f0930fbf5
Merge pull request #419 from healthchecks/snyk-fix-3b4d7e5e456fc8fadd61239890135796
[Snyk] Security upgrade django from 3.1 to 3.1.1
2020-09-03 11:00:41 +03:00
snyk-bot
c84626040c
fix: requirements.txt to reduce vulnerabilities
The following vulnerabilities are fixed by pinning transitive dependencies:
- https://snyk.io/vuln/SNYK-PYTHON-DJANGO-609368
- https://snyk.io/vuln/SNYK-PYTHON-DJANGO-609369
2020-09-02 22:16:56 +00:00
Pēteris Caune
0362df55ba
Docs: update the "Filtering Rules" section with the new options. 2020-09-01 15:00:41 +03:00
Pēteris Caune
ad720af242
Rename "hc-p-channels" to "hc-channels" 2020-09-01 12:56:35 +03:00
Pēteris Caune
5ebb5958ea
Remove unused "project" parameter in Pushbullet tests. 2020-09-01 12:18:24 +03:00
Pēteris Caune
9ba9032389
Cleaner OAuth redirect_uri generation 2020-09-01 12:07:13 +03:00
Pēteris Caune
d1b1a6c02e
The LINE Notify integration uses OAuth2 flow. 2020-09-01 11:38:08 +03:00
Pēteris Caune
4f53325730
THe LINE Notify integration uses OAuth2 flow. 2020-09-01 11:37:54 +03:00
Pēteris Caune
25a8ec6bd9
Capitalize title 2020-08-31 13:01:45 +03:00
Pēteris Caune
b4ba582255
Docs: add the "Viewing cron logs using journalctl" section 2020-08-31 12:51:24 +03:00
Pēteris Caune
ae578a29c2
Docs: add "Using Runitor" and "Handling More Than 10KB of Logs" sections 2020-08-31 12:32:16 +03:00
Pēteris Caune
a2c123c74b
Docs: add a section about read-only team members 2020-08-31 11:33:06 +03:00
Pēteris Caune
0a85c5ed12
In Account Settings > My Projects, indicate read-only memberships as read-only 2020-08-31 11:07:39 +03:00
Pēteris Caune
e424176a1f
Remove mentions of "whitelist" 2020-08-26 16:38:29 +03:00
Pēteris Caune
b2a1c0d343
Set USE_L10N to False until we've fixed issues caused by decimal comma formatting in templates. Fixes #416 2020-08-26 16:15:29 +03:00
Pēteris Caune
d73de68f70
Specify the read-write/read-only flag when inviting a team member. 2020-08-26 16:09:17 +03:00
Pēteris Caune
adb004b333
Read-only users cannot change project settings. 2020-08-26 15:04:12 +03:00
Pēteris Caune
39198c827a
Read-only users cannot edit or remove channels. 2020-08-26 14:48:31 +03:00
Pēteris Caune
24c34430ac
Read-only users cannot resume checks. 2020-08-26 14:12:52 +03:00
Pēteris Caune
bdf99e0ea7
The "Add Integration" pages require read-write access. 2020-08-26 14:06:51 +03:00
Pēteris Caune
c9baa2d8eb
Read-only users cannot toggle channels on and off. 2020-08-26 12:50:02 +03:00
Pēteris Caune
024d0adb9c
Read-only users cannot copy, transfer or remove checks. 2020-08-26 12:44:55 +03:00
Pēteris Caune
cbd7ffbffb
Read-only users cannot edit filtering rules. 2020-08-26 12:36:05 +03:00
Pēteris Caune
11d8e6197c
Read-only users cannot add checks.
Read-only users cannot pause checks.
2020-08-26 12:29:03 +03:00
Pēteris Caune
00790dc33c
Member.rw flag. Read-only users cannot edit check's name/desc/tags or schedule 2020-08-26 12:16:43 +03:00
Pēteris Caune
84cc33412a
When copying a check, copy all fields from the "Filtering Rules" dialog 2020-08-26 10:08:37 +03:00
Pēteris Caune
40f95d5a56
When copying a check, also copy the "failure keyword" field Fixes #417 2020-08-26 10:00:49 +03:00
Pēteris Caune
a5e1343a66
Merge pull request #415 from xakraz/master
Updated REAMDE.md: Add Slack integration instructions
2020-08-21 18:02:53 +03:00
Xavier Krantz
dd5ca9d783 Updated REAMDE.md: Add Slack integration instructions 2020-08-21 14:49:59 +02:00
Pēteris Caune
11c02d89c1
Go usage example in docs 2020-08-20 13:56:43 +03:00
Pēteris Caune
33639964b8
Add LINE Notify icon to the icon font. 2020-08-20 11:37:30 +03:00
Pēteris Caune
94b993354f
Sort integrations in A-Z order. Rename "LineNotify" -> "LINE Notify". Update the LINE Notify icon. 2020-08-20 11:16:59 +03:00
carson.wang
f15e16a0bb
Remove HTML markup 2020-08-20 10:42:06 +03:00
carson.wang
74668551a7
Add tests & Doesn't get LineNotify token using setting 2020-08-20 10:42:04 +03:00
carson.wang
65b65188d1
Test LineNotify integration with healthcheck 2020-08-20 10:42:00 +03:00
Pēteris Caune
2346ac3e80
Bugfix: don't allow duplicate team memberships 2020-08-19 12:07:48 +03:00
Pēteris Caune
9a1127005e
Link to the "Security" section in dashboard's README 2020-08-18 14:21:38 +03:00
Pēteris Caune
b7e2404f98
Host a read-only dashboard (from github.com/healthchecks/dashboard/), link to it from "Project Settings" > "Show API keys" 2020-08-18 14:07:55 +03:00
Pēteris Caune
c75a37570e
In channels admin, don't show the notification counts, querying it is too expensive. 2020-08-18 13:30:24 +03:00
Pēteris Caune
c7af52637a
Less verbose output in the senddeletionnotices command 2020-08-18 11:05:04 +03:00
Pēteris Caune
8ea510cda6
Removing unused /api/v1/notifications/{uuid}/bounce endpoint 2020-08-17 13:18:39 +03:00
Pēteris Caune
a29b82a0ed
In api.views.notification_status, always return HTTP 200 so the other party doesn't retry over and over again 2020-08-17 13:10:07 +03:00
Pēteris Caune
697cb19bde
Handle excessively long email addresses in the team member invite form. 2020-08-17 12:05:19 +03:00
Pēteris Caune
ffafc16fe5
Handle excessively long email addresses in the signup form. 2020-08-17 11:31:24 +03:00
Pēteris Caune
8223b0c402
Merge pull request #411 from henrywhitaker3/master
Added PHP wrapper to docs
2020-08-12 12:35:40 +03:00
Henry Whitaker
77f81b82e7
Merge pull request #1 from henrywhitaker3/henrywhitaker3-patch-1
Update resources.html
2020-08-12 09:54:24 +01:00
Henry Whitaker
cef71b1159
Added PHP wrapper to docs 2020-08-12 09:53:11 +01:00
Henry Whitaker
99b0786f19
Update resources.html 2020-08-12 09:40:04 +01:00
Pēteris Caune
b63f3bed8e
Limit project name to 60 characters to prevent abuse 2020-08-10 11:23:59 +03:00
Pēteris Caune
f131123e0e
In the test_it_sends_link testcase, explicitly set the USE_PAYMENTS setting. This way tests work regardless of what's in the environment variable or local_settings.py file. 2020-08-05 17:35:37 +03:00
Pēteris Caune
96d458fcf3
Merge pull request #409 from iphoting/patch-1
Fix logic bug in test_signup (#408)
2020-08-05 17:32:34 +03:00
Ronald Ip
c476f042ba
Fix logic bug in test_signup (#408)
Resolves #408 by fixing the test_signup logic bug introduced in 8c13457.
2020-08-05 22:27:44 +08:00
Pēteris Caune
ae01c7a9d1
Handle Twilio status callbacks for SMS, WhatsApp and phone call notifications. 2020-08-05 17:12:23 +03:00
Pēteris Caune
95d58d26d5
Handle status callbacks from Twilio, show SMS delivery failures in the Integrations page. 2020-08-05 16:10:30 +03:00
Pēteris Caune
750b96c374
Use Django 3.1 2020-08-05 13:11:39 +03:00
Pēteris Caune
9edb8aa08d
Update changelog for v1.16.0 release. 2020-08-04 19:24:41 +03:00
Pēteris Caune
2ed9a8fd30
Rename Channel.sms_number property to Channel.phone_number. It is now used for SMS, WhatsApp and phone call notifications, so "sms_number" is not accurate any more. 2020-08-04 16:26:13 +03:00
Pēteris Caune
732df19374
Fix the "Paid plan required" notice. 2020-08-04 16:01:58 +03:00
Pēteris Caune
d05691f86f
SMS and phone calls now have separate "limit reached" email templates. 2020-08-03 18:00:48 +03:00
Pēteris Caune
8c13457037
Use separate counters for SMS and phone calls. 2020-08-03 17:52:09 +03:00
Pēteris Caune
77ee8452c5
Update docs. 2020-07-29 19:29:34 +03:00
Pēteris Caune
ee9ac0ffef
New integration: phone calls. Fixes #403 2020-07-29 18:30:50 +03:00
Pēteris Caune
43e56ce788
Add support for multiple, comma-separated keywords (cc: #396) 2020-07-23 12:06:17 +03:00
Pēteris Caune
5acea4c89d
Update Node.js pinging examples -- handle the 'error' event. 2020-07-22 18:36:11 +03:00
Pēteris Caune
1ff7b2c581
Merge pull request #406 from UniversitaDellaCalabria/locale-it
IT localization
2020-07-22 15:02:17 +03:00
Giuseppe
ce50755314 IT localization 2020-07-22 13:43:06 +02:00
Pēteris Caune
ea896c907f
Translation tweaks 2020-07-22 11:05:26 +03:00
Pēteris Caune
fd14e0e03b
Experimental L10N support in base and welcome templates. cc: #404 2020-07-21 22:57:40 +03:00
Pēteris Caune
519a666057
{% site_name %} -> {{ site_name }} so we can use blocktrans tags for L10N 2020-07-21 17:59:39 +03:00
Pēteris Caune
0d03e3f00b
Add "Failure Keyword" filtering for inbound emails (cc: #396) 2020-07-21 14:57:48 +03:00
Pēteris Caune
556e8c67c5
Syntax highlighting for PHP examples. 2020-07-17 19:55:11 +03:00
Pēteris Caune
59e566117b
Update pinging examples. 2020-07-17 17:21:10 +03:00
Pēteris Caune
6834adf878
Django 3.0.8 2020-07-17 16:51:39 +03:00
Pēteris Caune
028e131327
Update pinging examples. 2020-07-17 16:51:23 +03:00
Pēteris Caune
589c0c0363
Updated Discord integration to use discord.com instead of discordapp.com 2020-07-17 13:36:41 +03:00
Pēteris Caune
f814035f03
Declutter /admin/accounts/profile/ 2020-07-16 16:31:57 +03:00
Pēteris Caune
255d4e7bb7
Reduce the number of queries in /admin/api/channel/ 2020-07-16 16:15:58 +03:00
Pēteris Caune
ec5ee03a3e
Add "check_id" in Spike payload. 2020-07-15 17:56:18 +03:00
Pēteris Caune
f789cad2af
Handle HTTP 429 responses from Matrix server when joining a Matrix room 2020-07-10 16:44:49 +03:00
Pēteris Caune
f5ceb612e0
Link to the Prometheus docs from the welcome page. 2020-07-10 15:24:19 +03:00
Pēteris Caune
80fdfbfa59
Add Spike icon in the iconfont (cc: #393) 2020-07-10 15:12:33 +03:00
Pēteris Caune
62fe42e953
".field-email span" selector was too broad and affecting profile details page, fixed. 2020-07-09 18:45:51 +03:00
Pēteris Caune
58f16da935
Edits to Spike setup instructions. 2020-07-09 11:22:14 +03:00
Pēteris Caune
1f978ff80e
Fix tests. 2020-07-09 10:48:51 +03:00
Divyansh
6300947c77
integration for Spike 2020-07-09 10:44:40 +03:00
Pēteris Caune
d34854f838
Update bash examples with the "-m" parameter. 2020-07-08 17:19:13 +03:00
Pēteris Caune
911293e1d2
Add a few missing meta description tags. 2020-07-08 16:10:30 +03:00
Pēteris Caune
3f44eac485
Update the section about read-write and read-only API keys. 2020-07-08 13:55:46 +03:00
Pēteris Caune
c160045bda
Update the section about read-write and read-only API keys. 2020-07-08 13:52:31 +03:00
Pēteris Caune
d6c0d9722b
Use PING_URL placeholder in the PHP example. 2020-07-07 21:22:56 +03:00
Pēteris Caune
2510e387e6
Use PING_URL placeholder in the PHP example. 2020-07-07 21:17:31 +03:00
Pēteris Caune
4324843c41
Merge pull request #395 from smknstd/master
[docs] add php curl example with timeout and retry options
2020-07-07 20:57:40 +03:00
Arnaud Becher
2cb0ac907d add php curl example with timeout and retry options 2020-07-07 18:36:41 +02:00
Pēteris Caune
e89229a2ca
In admin, visualize account's number of checks 2020-07-06 18:39:27 +03:00
Pēteris Caune
27a91bfe22
Tweak navigation in docs, added "Docs > Reliability Tips" page (cc: #384) 2020-07-02 18:39:30 +03:00
Pēteris Caune
df65ec9d89
Adding pauldenver/healthchecks-io-client to the 3rd party resources page 2020-07-02 12:15:32 +03:00
Pēteris Caune
f573578108
Some JS linting fixes 2020-07-01 19:23:50 +03:00
Pēteris Caune
3a00c0d2aa
Sending a test notification updates Channel.last_error. Fixes #391 2020-07-01 14:03:11 +03:00
Pēteris Caune
ae4918db86
CSS tweaks: do slightly less white-on-white painting 2020-07-01 12:32:28 +03:00
Pēteris Caune
1e53027b84
CSS tweaks: do slightly less white-on-white painting 2020-06-30 12:39:12 +03:00
Pēteris Caune
5b3928ce79
render_docs 2020-06-26 10:11:08 +03:00
Pēteris Caune
192e72c243
Edit Prometheus guide, add "API Keys" screenshot. 2020-06-26 10:10:59 +03:00
Pēteris Caune
3c461473ec
Merge pull request #387 from issmirnov/prometheus-docs
Create configuring_prometheus.md
2020-06-26 09:49:30 +03:00
Ivan Smirnov
634b525d1a generate html assets 2020-06-25 15:06:09 -07:00
Ivan Smirnov
0b5fa40f68 Create configuring_prometheus.md
Add documentation on how to export metrics to prometheus.
2020-06-25 15:00:20 -07:00
Pēteris Caune
149096811d
In the checks list, indicate a started check with a progress spinner under the status icon (cc: #338) 2020-06-25 16:44:25 +03:00
Pēteris Caune
a18eb134f5
Refactor: change Check.get_status(with_started=...) default value from True to False (with_started=False is or will be useful in more places) 2020-06-25 15:23:59 +03:00
Pēteris Caune
eccc193b87
In the cron expression dialog, show a human-friendly version of the expression 2020-06-19 11:25:46 +03:00
Pēteris Caune
a3b58d25ff
Change "--output" to "-o" in curl examples. 2020-06-15 16:14:03 +03:00
Pēteris Caune
bd3f150284
Merge pull request #380 from Simonmicro/master
Switched from pipeing to --output /dev/null for curl
2020-06-15 15:57:59 +03:00
Pēteris Caune
84889d6160
Render docs. 2020-06-15 13:28:20 +03:00
Pēteris Caune
276c36841a
Merge branch 'jameskirsop-return-single-history' 2020-06-15 13:27:37 +03:00
Pēteris Caune
5ab09f61f7
Update changelog 2020-06-15 13:26:56 +03:00
Pēteris Caune
c3d8ee0965
Update API docs. 2020-06-15 13:25:55 +03:00
Pēteris Caune
832580f343
Simplify hc.api.views.flips, add validation and more tests. 2020-06-15 13:08:17 +03:00
Pēteris Caune
60d1c6e2a3
Format timestamp as ISO 8601 without microseconds, same as elsewhere. 2020-06-15 12:20:07 +03:00
Pēteris Caune
a90f8a3a56
Remove unused code. 2020-06-15 12:17:15 +03:00
Pēteris Caune
f9c10d99c1
Merge pull request #383 from makom/master
fix typo
2020-06-15 12:05:05 +03:00
James Kirsop
368d7a4fec Commit with requested changes and tests 2020-06-15 13:15:57 +10:00
Martin
c11526a05d
Merge pull request #1 from makom/fix-typo
fix typo
2020-06-14 17:22:43 +02:00
Martin
bc0684df63
fix typo 2020-06-14 14:51:11 +02:00
James Kirsop
c5c4e0f782 Returning all historical flips if no parameters are passed 2020-06-12 17:42:45 +10:00
James Kirsop
7d625cb6a6 Merge branch 'return-single-history' of https://github.com/jameskirsop/healthchecks into return-single-history 2020-06-12 13:39:13 +10:00
James Kirsop
90d4246848 Second interation of this 2020-06-12 13:39:03 +10:00
James Kirsop
4b1b232959 Chnange 'status' field in response to 'up' 2020-06-12 09:16:59 +10:00
James Kirsop
bc6ccd55b3 Implementation of history using Flips model statuses for a check 2020-06-12 09:16:59 +10:00
James Kirsop
aaadf6031f Sample work for review 2020-06-12 09:16:59 +10:00
Simon Beginn
4592987810 Switched from piping to --output /dev/null for curl 2020-06-11 20:30:41 +02:00
Pēteris Caune
beff11ceff
Fixing typo 2020-06-11 16:07:04 +03:00
Pēteris Caune
01fafd9908
Merge branch 'jameskirsop-Retrieve-check-by-unique_key' 2020-06-11 15:25:27 +03:00
Pēteris Caune
cdafc06c65
In urls.py, route "api/v1/checks/<sha1:unique_key>" directly to the hc.api.views.get_check_by_unique_key view.
Minor API documentation edits.
2020-06-11 15:24:45 +03:00
James Kirsop
8725c81144 Implementing new changes discussed to resolve #370 2020-06-11 17:00:27 +10:00
Pēteris Caune
fd4d59c4e1
API, optimization: avoid retrieving project twice from the database 2020-06-09 18:51:42 +03:00
Pēteris Caune
0e5d578360
Update _get_events to work same way as hc.api.views.pings (iterate over pings in ascending order) 2020-06-09 18:41:09 +03:00
Pēteris Caune
a07325e40f
Add "Get a list of checks's logged pings" API call (#371) 2020-06-09 18:09:57 +03:00
Pēteris Caune
461ef5e088
Paused ping handling can be controlled via API. Fixes #376 2020-06-09 15:16:39 +03:00
Pēteris Caune
8e51d26595
Removing Pager Team integration, project appears to be discontinued 2020-06-09 13:26:15 +03:00
Pēteris Caune
ffc45f0c74
Update CHANGELOG for release. 2020-06-04 15:07:34 +03:00
Pēteris Caune
4f1f06e29f
Merge pull request #374 from healthchecks/snyk-fix-e4c69a4ee669f785e6b47fee436364ef
[Snyk] Security upgrade django from 3.0.4 to 3.0.7
2020-06-04 15:05:30 +03:00
snyk-bot
b2175c9260 fix: requirements.txt to reduce vulnerabilities
The following vulnerabilities are fixed by pinning transitive dependencies:
- https://snyk.io/vuln/SNYK-PYTHON-DJANGO-571013
- https://snyk.io/vuln/SNYK-PYTHON-DJANGO-571014
2020-06-03 22:17:38 +00:00
Pēteris Caune
3eebd8968d
Added "When paused, ignore pings" option in the Filtering Rules dialog (#369) 2020-06-02 10:54:16 +03:00
Pēteris Caune
5c8b5b7b63
adaptiveSetInterval fires the first request immediately if runNow is true, in 3 seconds otherwise. 2020-06-01 11:51:40 +03:00
Pēteris Caune
cfb294862f
DRY, have a single "No billing address" modal dialog. 2020-05-29 15:33:33 +03:00
Pēteris Caune
95279f6f3f
Billing page allows setting up a subscription before a payment method is added. 2020-05-29 15:08:00 +03:00
Pēteris Caune
9617be6e1b
Fix alignment of plan columns. 2020-05-06 12:00:56 +03:00
Pēteris Caune
c70a2588c6
Update package versions 2020-05-01 11:05:44 +03:00
Pēteris Caune
b433e91b48
Merge pull request #366 from bdd/master
Add runitor to resources.{md,html}
2020-05-01 10:47:51 +03:00
Berk D. Demir
eb279c4c21 Add runitor to resources.{md,html}
From its README:

Why Do I Need This Instead of Calling curl from a Shell Script?

In addition to clean separation of concerns from the thing that needs to
run and the act of calling an external monitor, runitor packs a few neat
extra features that are bit more involved than single line additions to
a script.

It can capture the stdout and stderr of the command to send it along
with execution reports, a.k.a. pings. When you respond to an alert you
can quickly start investigating the issue with the relevant context
already available.

It can be used as a long running process acting as a task scheduler,
executing the command at specified intervals. This feature comes in
handy when you don't readily have access to a job scheduler like crond
or systemd.timer. Works well in one process per container environments.
2020-04-30 19:43:27 -07:00
Pēteris Caune
3730c67c80
Return max notification_id in metrics. 2020-04-26 20:34:52 +03:00
Pēteris Caune
98310eeeaa
Include timestamp in the metrics response. 2020-04-26 19:34:36 +03:00
Pēteris Caune
edbfd4b437
Added /api/v1/metrics/ endpoint, useful for monitoring the service itself 2020-04-26 17:45:50 +03:00
Pēteris Caune
7994259003
When an invited user logs in, redirect them to the new project 2020-04-24 14:46:43 +03:00
Pēteris Caune
fbd8419700
CSS tweaks in the welcome page, fix footer margin. 2020-04-24 14:02:55 +03:00
Pēteris Caune
9bfdbc4214
Fix login link. 2020-04-21 15:46:56 +03:00
Pēteris Caune
385021b44c
Don't let users clone checks if the account is at check limit 2020-04-20 19:34:35 +03:00
Pēteris Caune
e04a92ccf1
Profiles admin: filtering by number of checks, show check count by project. 2020-04-20 19:11:15 +03:00
Pēteris Caune
3cca17560a
Fix tests. 2020-04-20 17:11:00 +03:00
Pēteris Caune
00ea45655d
In checks list, the pause button asks for confirmation. Fixes #356 2020-04-20 17:09:48 +03:00
Pēteris Caune
825110a354
Channel icons in Admin > Channels 2020-04-20 13:56:24 +03:00
Pēteris Caune
abdff95ce8
Admin tweaks. 2020-04-20 13:33:21 +03:00
Pēteris Caune
c057dbfb2c
Cleanup. 2020-04-20 11:54:27 +03:00
Pēteris Caune
6ede17d93f
Cleanup and comments. 2020-04-20 11:23:07 +03:00
Pēteris Caune
d6bb2b5435
Merge pull request #360 from bdd/master
Remove redundant '-X POST' to curl
2020-04-19 11:47:11 +03:00
Berk D. Demir
34807dc5aa Remove redundant '-X POST' to curl
Passing `--data-raw` to curl implies the request is method will be POST.
Unless we intend to do something entirely different, -X method override
shouldn't be used.

Curl's author Daniel Stenberg (@bagder) wrote about this back in 2015
https://daniel.haxx.se/blog/2015/09/11/unnecessary-use-of-curl-x/
2020-04-18 15:05:17 -07:00
Pēteris Caune
dda08a6143
capitalize plan's name 2020-04-14 10:30:59 +03:00
Pēteris Caune
4331497ccd
Merge pull request #359 from SuperSandro2000/typos
Fix typos with codespell
2020-04-14 10:28:01 +03:00
Sandro Jäckel
38382d662d
Fix typos with codespell 2020-04-14 03:53:16 +02:00
Pēteris Caune
ca715dd8d4
Check membership when initiating project's transfer. Use transaction.atomic() when completing the transfer. 2020-04-13 15:19:37 +03:00
Pēteris Caune
57da17b8e2
Send an "Ownership Transfer Request" email notification. 2020-04-13 15:04:59 +03:00
Pēteris Caune
da954000fd
Remove unused CSS 2020-04-13 13:40:56 +03:00
Pēteris Caune
3bf1ad9746
Fix invite suggestions. 2020-04-13 12:26:05 +03:00
Pēteris Caune
532b752e3c
cleanup: don't import each form individually 2020-04-13 12:16:39 +03:00
Pēteris Caune
f7acaa57af
Adding tests. 2020-04-12 18:21:08 +03:00
Pēteris Caune
f42b2b144a
New feature: Project Settings > Transfer Ownership (WIP, missing tests) 2020-04-12 14:46:12 +03:00
Pēteris Caune
cb19bac70f
Merge pull request #358 from lobovkin/lobovkin-patch-1
Using existing function getAmount
2020-04-09 10:39:48 +03:00
Anton Lobovkin
4e0460c69b
Using existing function getAmount 2020-04-08 22:58:31 +02:00
Pēteris Caune
a982ad7123
Tooltips and updated FAQ in the pricing page. 2020-04-07 14:35:21 +03:00
Pēteris Caune
f1880657fd
Added "Supporter" billing plan. 2020-04-07 12:32:20 +03:00
Pēteris Caune
733c589e47
Section labels in the welcome tour. 2020-04-07 10:12:46 +03:00
Pēteris Caune
8c7d3570a5
Remove unused imports, cleanup. 2020-04-07 10:08:20 +03:00
Pēteris Caune
c596f485a5
DRY: adding "now_isoformat" template tag 2020-04-06 15:02:49 +03:00
Pēteris Caune
92542fa818
"Edit Webhook Parameters" button in the "Edit Name" modal. 2020-04-06 14:52:47 +03:00
Pēteris Caune
609f78c5ed
"Edit" function for webhook integrations (#176) 2020-04-06 14:48:47 +03:00
Pēteris Caune
f12a649c72
Fix tests. 2020-04-06 13:36:46 +03:00
Pēteris Caune
a1791ea404
Make sure long project names don't break layout. 2020-04-06 12:29:26 +03:00
Pēteris Caune
56bb49f1f3
Use Slack V2 OAuth flow 2020-04-02 10:57:10 +03:00
James Kirsop
74f4744c62 Implementation of history using Flips model statuses for a check 2020-03-27 14:19:57 +11:00
James Kirsop
010bbc9507 Sample work for review 2020-03-27 09:30:26 +11:00
Pēteris Caune
9d2cf4f008
Don't escape HTML in the subject line of notification emails 2020-03-25 17:18:14 +02:00
Pēteris Caune
4a43ed59fc
Rate limiting for Telegram notifications (10 notifications per chat per minute) 2020-03-24 23:33:02 +02:00
Pēteris Caune
76ae42bc8f
"Get a single check" API call now supports read-only API keys. Fixes #346 2020-03-24 16:10:42 +02:00
Pēteris Caune
5a297ba6a2
v1.14.0 2020-03-23 12:34:42 +02:00
Pēteris Caune
f1750a5f6e
Add Zulip in the Welcome page. 2020-03-23 12:25:30 +02:00
Pēteris Caune
119965b432
Change the order of fields in slack notifications: start with description, project name and tags. Follow with period, last ping, total pings. 2020-03-23 12:23:25 +02:00
Pēteris Caune
1baa8ad46d
Merge pull request #342 from sairam/patch-1
Introduce Project Name in Slack notification
2020-03-23 12:13:58 +02:00
Pēteris Caune
abebdca527
Docs: PING_URL substitution got lost during refactoring, adding it back 2020-03-23 12:01:40 +02:00
Pēteris Caune
da4cf5241e
Minor cleanup, update CHANGELOG 2020-03-23 11:54:41 +02:00
Pēteris Caune
ffc7ccddf2
Merge branch 'jameskirsop-api-single-check' 2020-03-23 11:42:31 +02:00
James Kirsop
613ef2d0cf Merge branch 'api-single-check' of https://github.com/jameskirsop/healthchecks into api-single-check 2020-03-23 11:39:23 +11:00
James Kirsop
456a80f1fa Adding tests and docs 2020-03-23 11:37:32 +11:00
James Kirsop
6373db8aa1 Changes to prototype this for testing with real data 2020-03-23 10:58:02 +11:00
Sai Ram Kunala
9c9be4f181
Update 'Project Name' to 'Project' 2020-03-20 10:50:20 +05:30
Pēteris Caune
25d7d5409f
Telegram integration returns more detailed error messages 2020-03-19 22:16:22 +02:00
Pēteris Caune
5f2c20e46b
Zulip integration returns more detailed error messages 2020-03-19 22:05:13 +02:00
Pēteris Caune
8c7f3977e2
OpsGenie integration returns more detailed error messages 2020-03-19 21:58:17 +02:00
Sai Ram Kunala
c9979cc125
Introduce Project Name in Slack notification 2020-03-14 17:48:51 +05:30
Pēteris Caune
50118d90c5
Remove an extra quote. 2020-03-11 16:47:26 +02:00
Pēteris Caune
b689c8aa4e
Experimental Zulip integration. Fixes #202 2020-03-11 16:38:40 +02:00
Pēteris Caune
f352efdd5f
Experimental Zulip integration. Fixes #202 2020-03-11 16:38:29 +02:00
Pēteris Caune
1cb2ec16fb
Fix wording 2020-03-10 15:53:06 +02:00
Pēteris Caune
5d513658e3
Adding Docs > Cloning Checks 2020-03-10 15:43:34 +02:00
Pēteris Caune
bf1294a100
Docs / Shell scripts: add "Auto-provisioning New Checks" section 2020-03-09 18:05:21 +02:00
Pēteris Caune
ab692236eb
Fix selectize initialization when the project has 0 existing tags. 2020-03-09 14:39:03 +02:00
Pēteris Caune
26ad94d068
If the project has no integrations, show an appropriate message in the Details page, "Notification Methods" section. 2020-03-09 12:57:24 +02:00
Pēteris Caune
c8ebf73058
Merge pull request #340 from healthchecks/snyk-fix-51c0d13c6d0c4055f57c0eb711c34f20
[Snyk] Security upgrade django from 3.0.3 to 3.0.4
2020-03-09 10:17:35 +02:00
Pēteris Caune
6147451851
JS cleanup. 2020-03-09 10:16:39 +02:00
Pēteris Caune
3e25e5c242
Set the correct SMS limit when cancelling a paid plan. 2020-03-09 09:50:48 +02:00
snyk-bot
75cb95ffec fix: requirements.txt to reduce vulnerabilities
The following vulnerabilities are fixed by pinning transitive dependencies:
- https://snyk.io/vuln/SNYK-PYTHON-DJANGO-559326
2020-03-05 22:17:56 +00:00
Pēteris Caune
fcf11d5b4f
Reduce the number of SQL queries in the "Check Details" page. 2020-03-05 16:15:02 +02:00
Pēteris Caune
eb7f51f6f5
Focus the "name" input in the "Add Project" modal. 2020-03-05 16:05:06 +02:00
Pēteris Caune
00810ff123
Use Selectize.js for entering tags. Fixes #324 2020-03-05 15:49:42 +02:00
Pēteris Caune
35e476be59
Document more response codes. 2020-03-04 12:12:38 +02:00
Pēteris Caune
2e30d349aa
Tweak CSS for form controls in focused state. 2020-03-04 11:42:50 +02:00
Pēteris Caune
db9593c571
Unused, removing. 2020-03-02 16:43:47 +02:00
Pēteris Caune
ccba5e8731
Fix default values for timeout and grace parameters in API reference. 2020-03-02 13:50:27 +02:00
Pēteris Caune
dab0c4200e
API reference in Markdown 2020-03-02 13:37:29 +02:00
Pēteris Caune
516143de8a
Import hc.front.forms instead of importing each form individually 2020-03-02 10:12:57 +02:00
Pēteris Caune
22ef024885
Use secrets.token_urlsafe 2020-03-02 10:04:41 +02:00
Pēteris Caune
8bbf85a397
Remove Profile.current_project field. Fixes #336 2020-03-02 09:57:39 +02:00
Pēteris Caune
dd3820c0d5
_get_check_for_user and _get_channel_for_user are always be used with an authenticated user, so don't need to handle the unauthenticated case. 2020-03-01 22:45:33 +02:00
Pēteris Caune
4bcfba728e
Use unittest.mock 2020-03-01 22:30:12 +02:00
Pēteris Caune
d3ee9bae0e
Fix typo 2020-02-28 10:28:45 +02:00
Pēteris Caune
490362638f
Documentation: notes about resource limits 2020-02-27 17:51:22 +02:00
Pēteris Caune
dab15c3b8c
Link integration setup instructions from the welcome page (only the ones that don't require authentication: Slack, Pushover, PagerDuty Connect, Telegram) 2020-02-27 16:32:31 +02:00
Pēteris Caune
29e016d0fc
Update Telegram instructions. Fix redirect after login when adding Telegram integration. 2020-02-27 15:52:00 +02:00
Pēteris Caune
0c9c453ea0
Profile.current_project not used any more, remove last remaining references. cc: #336 2020-02-27 12:34:21 +02:00
Pēteris Caune
93b48ce720
In setup instructions, show an additional "log ina adn go to the Integrations" page for logged-out users 2020-02-27 12:16:42 +02:00
Pēteris Caune
9389408cbc
The "require_setting" decorator and more tests. 2020-02-27 11:35:18 +02:00
Pēteris Caune
dc373dc054
CSS counters for integration setup instructions. 2020-02-27 11:24:12 +02:00
Pēteris Caune
b5b5c58d77
Split "Add Pagerduty Connect" in three views for clarity. 2020-02-27 10:28:14 +02:00
Pēteris Caune
157711bc95
Reduce usage of Profile.current_project cc: #336 2020-02-26 10:56:17 +02:00
Pēteris Caune
6a0c90853b
request.project is now unused, removing 2020-02-26 10:37:19 +02:00
Pēteris Caune
9c3f7101db
Don't use request.project in the pricing page cc: #336 2020-02-26 10:27:45 +02:00
Pēteris Caune
bb808852d9
Reduce usage of request.project cc: #336 2020-02-25 15:39:54 +02:00
Pēteris Caune
318934697f
Remove last references of the hc-channels route. 2020-02-25 15:26:33 +02:00
Pēteris Caune
f2375f9f45
Don't redirect to /integrations/, redirect to /project/<uuid>/integrations/ 2020-02-25 15:19:20 +02:00
Pēteris Caune
7060d49306
The "Add Telegram" page shows a project picker. cc: #336 2020-02-25 14:51:39 +02:00
Pēteris Caune
acce0808ce
Project code in URL for the "Add Slack" page. cc: #336 2020-02-25 14:22:34 +02:00
Pēteris Caune
dee189be33
Project code in URL for the "Add Trello" page. cc: #336 2020-02-25 11:24:32 +02:00
Pēteris Caune
26757c6785
Clean up Pushover validation. 2020-02-25 11:05:52 +02:00
Pēteris Caune
f6f2b18c5d
Project code in URL for the "Add Pushover" page. cc: #336 2020-02-25 10:48:58 +02:00
Pēteris Caune
ea333f7ac1
Project code in URL for the "Add PagerDuty (Connect)" page. cc: #336 2020-02-25 10:14:42 +02:00
Pēteris Caune
f13ad875a1
Project code in URL for the "Add Discord" page. cc: #336 2020-02-25 09:57:11 +02:00
James Kirsop
d88f99a712 Changes to prototype this for testing with real data 2020-02-25 12:48:54 +11:00
Pēteris Caune
38bd84cc91
Project code in URL for the "Add Pushbullet" page. cc: #336 2020-02-21 17:31:17 +02:00
Pēteris Caune
44819cb555
Project code in URL for the "Add PagerDuty" page. cc: #336 2020-02-21 15:47:45 +02:00
Pēteris Caune
81f9a604e1
Project code in URL for the "Add Shell" page. cc: #336 2020-02-21 15:44:55 +02:00
Pēteris Caune
88f2a01182
Project code in URL for the "Add Apprise" page. cc: #336 2020-02-21 15:40:56 +02:00
Pēteris Caune
056134f2de
Project code in URL for the "Add WhatsApp" page. cc: #336 2020-02-21 15:33:42 +02:00
Pēteris Caune
9f5c133719
Project code in URL for the "Add VictorOps" page. cc: #336 2020-02-21 15:30:01 +02:00
Pēteris Caune
250935006d
Project code in URL for the "Add SMS" page. cc: #336 2020-02-21 15:26:06 +02:00
Pēteris Caune
f6a7d46058
Project code in URL for the "Add Prometheus" page. cc: #336 2020-02-21 15:22:10 +02:00
Pēteris Caune
5fb5b05f2e
Project code in URL for the "Add Pagertree" page. cc: #336 2020-02-21 15:18:15 +02:00
Pēteris Caune
1f950feee1
Fix Matrix test case. 2020-02-21 15:14:24 +02:00
Pēteris Caune
0ea2369dc0
Project code in URL for the "Add Pagerteam" page. cc: #336 2020-02-21 15:08:53 +02:00
Pēteris Caune
a6d497b21e
Project code in URL for the "Add OpsGenie" page. cc: #336 2020-02-21 15:03:26 +02:00
Pēteris Caune
d0b77febbc
Project code in URL for the "Add MS Teams" page. cc: #336 2020-02-21 14:58:22 +02:00
Pēteris Caune
70ff6c53e4
Project code in URL for the "Add Mattermost" page. cc: #336 2020-02-21 14:54:17 +02:00
Pēteris Caune
f8758e39ea
Project code in URL for the "Add Matrix" page. cc: #336 2020-02-21 14:44:44 +02:00
Pēteris Caune
59f5b7a5f5
Project code in URL for the "Add Webhook" page. cc: #336 2020-02-21 14:29:05 +02:00
Pēteris Caune
ea423e5420
Project code in URL for the "Integrations" and the "Add Email" pages. cc: #336 2020-02-21 14:15:13 +02:00
Pēteris Caune
9e82cbb412
Adding HealthChecksIOStatusReport in Third-Party resources. 2020-02-20 12:23:52 +02:00
Pēteris Caune
99bdc0ec8c
Tweak the integrations grid size in the welcome page. 2020-02-20 11:33:41 +02:00
Pēteris Caune
b5a4dada43
Add Prometheus in the welcome page. 2020-02-20 11:14:14 +02:00
Pēteris Caune
5e051d53f8
Validate channel identifiers before creating/updating a check. Fixes #335 2020-02-20 10:43:40 +02:00
Pēteris Caune
cde1f50ac2
API: update check's "alert_after" field when changing schedule 2020-02-19 12:45:33 +02:00
Pēteris Caune
fb527e4ed8
Security: check channel ownership when setting check's channels via API 2020-02-19 12:19:51 +02:00
Pēteris Caune
435659166c
Don't let SuspiciousOperation bubble up when validating channel ids in API 2020-02-19 11:43:42 +02:00
Pēteris Caune
7a0f3421dd
Setup instructions for Prometheus. 2020-02-18 16:48:01 +02:00
Pēteris Caune
3092eaf88d
Markdown with Pygments 2.4 and later wraps code in <code> tags (https://github.com/Python-Markdown/markdown/pull/862).
Reset CSS for code tags inside pre blocks.
2020-02-18 15:03:16 +02:00
Pēteris Caune
e52ac9af91
Put API key in the path (not query string) cc: #300 2020-02-14 16:39:31 +02:00
Pēteris Caune
12b946acf3
Experimental Prometheus metrics endpoint. cc: #300 2020-02-14 16:12:13 +02:00
Pēteris Caune
0ff4bd01e0
Improved UI to invite users from account's other projects. Fixes #258.
The team size limit is applied to the number of distinct users across all projects. Fixes #332.
2020-02-14 13:05:21 +02:00
Pēteris Caune
683dda9c5d
The "render_docs" command checks if markdown and pygments is installed. cc: #329 2020-02-14 10:16:43 +02:00
Pēteris Caune
82d61335b0
The "render_docs" command checks if markdown and pygments is installed. cc: #329 2020-02-14 10:14:29 +02:00
Pēteris Caune
174e5a7935
Update CHANGELOG for v1.13.0 2020-02-13 10:34:25 +02:00
Pēteris Caune
15b9611c5a
Show a warning in project's top navigation if the project has no configured integrations. Fixes #327 2020-02-13 10:29:01 +02:00
Pēteris Caune
c3608ac07c
Use t.me/username URL in the "Add Telegram" page. 2020-02-13 09:30:19 +02:00
Pēteris Caune
8ace6d5481
Merge pull request #328 from healthchecks/dependabot/pip/django-3.0.3
Bump django from 3.0.1 to 3.0.3
2020-02-13 09:25:25 +02:00
dependabot[bot]
ff383729cf
Bump django from 3.0.1 to 3.0.3
Bumps [django](https://github.com/django/django) from 3.0.1 to 3.0.3.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.0.1...3.0.3)

Signed-off-by: dependabot[bot] <support@github.com>
2020-02-11 21:12:35 +00:00
Pēteris Caune
c8ccd89af2
In hc.front.views.ping_details, if a ping does not exist, return a friendly message 2020-02-11 09:55:30 +02:00
Pēteris Caune
b0b6ee3149
In hc.front.views.ping_details, if a ping does not exist, return 404 instead of 500 2020-02-11 09:44:02 +02:00
Pēteris Caune
ccd30ac239
Stricter cron validation, reject schedules like "At midnight of February 31" 2020-02-07 11:38:50 +02:00
Pēteris Caune
4f6f1d9f66
Fix sendalerts crash loop when encountering a bad cron schedule 2020-02-07 10:36:45 +02:00
Pēteris Caune
ac4f1ca059
Log slow sendalerts.notify runs to stdout 2020-02-06 11:21:28 +02:00
Pēteris Caune
4a7074418a
Track the time spent sending notifications for each flip 2020-02-06 11:11:12 +02:00
Pēteris Caune
1b8460f39f
"Projects and Teams" in docs 2020-02-05 17:23:21 +02:00
Pēteris Caune
50280875cd
Typo 2020-02-05 11:32:01 +02:00
Pēteris Caune
9f2638bf72
The sendalerts commands measures notification dwell time and reports it over statsd protocol. Experimental, may go away in a future commit. 2020-02-05 11:25:06 +02:00
Pēteris Caune
5d9944873c
Don't trigger "down" notifications when changing schedule interactively in web UI 2020-02-05 10:31:20 +02:00
Pēteris Caune
6bc4948d00
Removing obsolete comment: the index is defined in hc.api.models.Check.Meta 2020-02-04 15:32:25 +02:00
Pēteris Caune
3048a20f9b
link rel="canonical" in the sign in page 2020-02-04 11:29:38 +02:00
Pēteris Caune
b1bffde3d6
Add SmartCronHelper 2020-02-04 11:26:49 +02:00
Pēteris Caune
e29b2387de
Illustrations in "Measuring script run time" page 2020-02-04 11:22:30 +02:00
Pēteris Caune
272360336b
"Configuring notifications" in docs 2020-02-03 17:41:06 +02:00
Pēteris Caune
1e721b8bcd
Docs: full width illustrations on smaller screens 2020-02-03 16:08:47 +02:00
Pēteris Caune
4bdc893fe0
Tweak footer height to avoid vertical scrollbar. 2020-02-03 16:03:16 +02:00
Pēteris Caune
5433cb1798
Fix README instructions on accessing Django admin (must log in first, then go to admin) 2020-02-03 11:28:39 +02:00
Pēteris Caune
b7d6f1bb30
link rel="canonical" on the welcome page 2020-02-03 11:17:24 +02:00
Pēteris Caune
f51a0a257e
Don't delete customer data in braintree when closing account.
Need customer data to stay in braintree until the end of each month for tax reports.
2020-02-03 11:11:21 +02:00
Pēteris Caune
b8cf428899
Merge pull request #325 from samyerkes/master
Updated default port in readme
2020-01-31 09:43:45 +02:00
Sam
b91f11588c
Merge pull request #1 from samyerkes/samyerkes-patch-1
Updated default port in readme
2020-01-29 20:56:20 -05:00
Sam
319d4528bb
Updated default port in readme
The default port after following the directions is actually 8000 instead of 8080.
2020-01-29 20:55:22 -05:00
Pēteris Caune
b8c0fd0eb9
Fix links to documentation. 2020-01-29 14:17:58 +02:00
Pēteris Caune
e2fe2edcc1
Title tags for documentation pages. 2020-01-29 13:54:54 +02:00
Pēteris Caune
564f69aca5
Adding shell example 2020-01-29 13:39:41 +02:00
Pēteris Caune
d29b0050a3
Fix endpoint address. 2020-01-29 12:45:00 +02:00
Pēteris Caune
dbd21c325d
Docs: "HTTP API" page 2020-01-29 12:43:35 +02:00
Pēteris Caune
d7de6476b7
Tweaking shell script examples 2020-01-28 16:44:32 +02:00
Pēteris Caune
a276c24dd3
Docs overhaul WIP 2020-01-28 14:07:06 +02:00
Pēteris Caune
74ab0d1931
Update CHANGELOG 2020-01-23 17:55:40 +02:00
Pēteris Caune
3e2ae02388
Added an example of capturing and submitting log output. Fixes #315 2020-01-23 17:53:23 +02:00
Pēteris Caune
f41c78e40f
Serve the introduction page at /docs/ 2020-01-23 16:58:28 +02:00
Pēteris Caune
50c8c153ea
Documentation in Markdown. 2020-01-23 16:04:15 +02:00
Pēteris Caune
7cf324872c
Replace the gear icon with three horizontal dots icon. Fixes #322.
Add a Pause button in the checks list. Fixes #312
2020-01-21 11:57:17 +02:00
Pēteris Caune
cdad632082
Show sub-second durations with higher precision, 2 digits after decimal point. Fixes #321 2020-01-17 14:41:41 +02:00
Pēteris Caune
77033760f9
Make sure Check.last_ping and Ping.created timestamps match exactly 2020-01-17 14:30:32 +02:00
Pēteris Caune
58a118c494
Make Ping.body size limit configurable. Fixes #301 2020-01-17 12:44:39 +02:00
Pēteris Caune
eae8d122b7
Update changelog. 2020-01-16 09:43:17 +02:00
Pēteris Caune
96797f6786
Merge pull request #320 from jerrykan/matrix_alias_length
Increase allowable length of Matrix room alias
2020-01-16 09:40:54 +02:00
John Kristensen
819aa227e9 Increase allowable length of Matrix room alias
The existing 40 character limit prevents using the integration will
Matrix servers that might have a fairly lengthy hostname (ie.
'matrix.internal.example.com' would only allow 12 characters for the
room name or ID, and room IDs are 19 characters long).

Increasing the `max_length` to `100` is still fairly arbitrary but it
does match up with the `max_length` of the `name` field of the `Channel`
model and should cover most instances.
2020-01-16 13:38:47 +11:00
Pēteris Caune
b8108906f4
hc.api.views.bounce updates Channel.last_error 2020-01-08 11:14:34 +02:00
Pēteris Caune
c521b44d20
hc.api.views.bounce handles transient email bounces (logs error, does not disable the integration) 2020-01-08 10:50:29 +02:00
Pēteris Caune
74ad152cc5
For superusers, show "Site Administration" in top navigation, note in README. Fixes #317 2020-01-07 12:15:09 +02:00
Pēteris Caune
c4edb415a2
Removing debug statement. 2020-01-07 11:47:53 +02:00
Pēteris Caune
012ad88bb3
createsuperuser management command requires an unique email address (#318) 2020-01-07 11:46:50 +02:00
Pēteris Caune
4ee2646539
Show a red "!" in project's top navigation if any integration is not working 2020-01-03 13:15:24 +02:00
Pēteris Caune
8e455965c4
Update changelog for v1.12.0 2020-01-02 12:38:11 +02:00
Pēteris Caune
52a178242b
2019 -> 2020. Cheers! 2020-01-02 10:10:20 +02:00
Pēteris Caune
18154dd6de
django-compressor==2.4, psycopg2==2.8.4 2020-01-02 10:08:44 +02:00
Pēteris Caune
3649c500d2
Don't allow adding email integrations with both "up" and "down" unchecked 2019-12-27 17:25:37 +02:00
Pēteris Caune
38ed309a3c
Don't allow adding webhook integrations with both URLs blank 2019-12-27 17:13:44 +02:00
Pēteris Caune
84a4de32cc
Remove legacy webhook formats (newline-separated fields and the post_data key) from the Channel model 2019-12-27 15:07:15 +02:00
Pēteris Caune
6ebae33579
Fix "Send Test Notification" for webhooks that only fire on checks going up 2019-12-27 14:36:32 +02:00
Pēteris Caune
be286518b7
For webhook integration, validate each header line separately 2019-12-27 13:56:33 +02:00
Pēteris Caune
057a6fe56b
Django 3.0.1 2019-12-19 15:59:36 +02:00
Pēteris Caune
830681d8f8
Specify encoding when reading CHANGELOG.md. Fixes #314 2019-12-19 09:59:10 +02:00
Pēteris Caune
0d2c6217d3
Auto-submit the unsubscribe confirmation form only if signature is more than 5 minutes old. Idea from https://stackoverflow.com/questions/59281750/strategies-to-prevent-email-scanners-from-activating-unsubscribe-links/59381066#59381066 2019-12-18 16:10:30 +02:00
Pēteris Caune
66c9fb33ad
Don't install django-compressor as editable package 2019-12-18 10:05:31 +02:00
Pēteris Caune
d9776e1340
Update pytz 2019-12-18 09:19:46 +02:00
Pēteris Caune
bffb51357e
Add desc to hc.api.schemas.check 2019-12-18 09:11:34 +02:00
Pēteris Caune
9697fc1b45
Merge pull request #313 from brammeleman/set-description
set/update the checks description through the API
2019-12-18 09:05:46 +02:00
Bram Daams
1b3d7e8c0a being able to set/update the description of a check when creating/updating using the api 2019-12-17 15:47:13 +01:00
Pēteris Caune
d6be955fa7
Silence stdout output from management commands during tests 2019-12-11 15:35:23 +02:00
Pēteris Caune
15ba415298
senddeletionnotices command skips profiles with recent last_active_date 2019-12-11 15:24:51 +02:00
Pēteris Caune
01bb03c889
django-compressor doesn't have a Django 3 compatible release yet. Use a development version temporarily. Details: https://github.com/django-compressor/django-compressor/issues/963 2019-12-11 13:31:55 +02:00
Pēteris Caune
b72979522b
Django 3 supports Python 3.6+. Adding the Py3.6 requirement to README. 2019-12-11 13:08:35 +02:00
Pēteris Caune
2a8e7ee766
Django 3.0 2019-12-11 13:05:25 +02:00
Pēteris Caune
eafff677d9
Don't auto-submit the unsubscribe form. Email security scanners like Office 365 Enterprise open links and *execute JS* causing users to automatically unsubscribe the first time they receive an email. Can't think of a sane fix for this :-( 2019-12-10 10:41:10 +02:00
Pēteris Caune
f7496fb8cf
Add List-Unsubscribe-Post email header 2019-12-10 09:44:51 +02:00
Pēteris Caune
0addbac7ba
Remove unused ask=1 parameters. 2019-12-10 09:27:30 +02:00
Pēteris Caune
8d81d27af3
Unsubscribe links serve a form, and require HTTP POST to actually unsubscribe 2019-12-10 09:14:54 +02:00
Pēteris Caune
4ee92a44ff
Unsubscribe is CSRF exempt. 2019-12-09 16:14:50 +02:00
Pēteris Caune
f9c61dad23
Fix List-Unsubscribe email header value: add angle brackets 2019-12-09 14:04:14 +02:00
Pēteris Caune
1cdb6e6d1d
Don't set CSRF cookie on first visit. Signup is exempt from CSRF protection. 2019-12-06 08:58:32 +02:00
Pēteris Caune
22d4d55340
Added support for Shields.io badges. cc: #304, #305 2019-12-05 12:27:37 +02:00
Pēteris Caune
838aee6bdd
Show Healthchecks version in Django admin header cc: #306 2019-12-03 17:41:58 +02:00
Pēteris Caune
5f47161e5e
staticfiles -> static 2019-12-03 09:59:36 +02:00
Pēteris Caune
87b232074c
Django 2.2.8 2019-12-02 12:30:52 +02:00
Pēteris Caune
7b32e9ef2c
Remove unused class="update-timeout-title" 2019-11-27 16:38:11 +02:00
Pēteris Caune
da095f2403
Merge branch 'master' of github.com:healthchecks/healthchecks 2019-11-27 16:34:09 +02:00
Pēteris Caune
3f19181028
"Filtering Rules" dialog, an option to require HTTP POST. Fixes #297 2019-11-27 16:33:36 +02:00
Pēteris Caune
87d75505fe
Merge pull request #307 from SuperSandro2000/patch-1
Add a note in README to run db migrations in production
2019-11-25 13:32:23 +02:00
Sandro
25f959c44b
Add hint to run db migration in production 2019-11-25 12:07:07 +01:00
Pēteris Caune
89a5fbb7f9
Optimize icons 2019-11-22 12:56:20 +02:00
Pēteris Caune
2893e370b6
Update CHANGELOG for release. 2019-11-22 12:03:50 +02:00
Pēteris Caune
1b005b6a9f
Update Changelog. 2019-11-22 11:43:47 +02:00
Pēteris Caune
5ab8486788
Update PagerDuty Connect setup illustrations. 2019-11-22 11:42:29 +02:00
Pēteris Caune
0349a3997b
PagerDuty event payload does not need the "vendor" key. 2019-11-22 11:29:09 +02:00
Pēteris Caune
f6d36b3491
Alternate flow for setting up PagerDuty integration, without using PD Connect 2019-11-22 11:17:14 +02:00
Pēteris Caune
d06721ab58
Rename "add_pd" to "add_pdc" (PagerDuty Connect). 2019-11-22 10:43:13 +02:00
Pēteris Caune
7c1b9c4b96
Rename "add_pd" to "add_pdc" (PagerDuty Connect). 2019-11-22 10:40:57 +02:00
Pēteris Caune
01955e4f99
Add MS Teams and Shell Commands to the list of integrations on Welcome page. 2019-11-21 16:01:41 +02:00
Pēteris Caune
98ba51f44f
Use hc.lib.string.replace for webhooks too.
hc.lib.string.replace only replaces placeholders that appear in the original template. It ignores any placeholders that "emerge" while doing string substitutions. This is done mainly to avoid unexpected behavior when check names or tags contain dollar signs.
2019-11-20 17:44:41 +02:00
Pēteris Caune
e4646205cb
Use channel.get_kind_display() in more places. 2019-11-20 17:31:36 +02:00
Pēteris Caune
fbba2b585e
Update PagerDuty logo in the icon font as well. 2019-11-20 17:10:41 +02:00
Pēteris Caune
5556bf3035
Update PagerDuty logo. 2019-11-20 16:46:31 +02:00
Pēteris Caune
c54c70cab7
Auto-focus the name field in the "Integration Details" modal. 2019-11-20 16:14:39 +02:00
Pēteris Caune
91c93b6a95
Add "Shell Commands" integration. Fixes #302 2019-11-20 16:01:03 +02:00
Pēteris Caune
8d81ea8f9d
Add "Shell Commands" integration. Fixes #302 2019-11-20 16:00:53 +02:00
Pēteris Caune
f74860bc0c
Add Profile.last_active_date field for more accurate inactive user detection 2019-11-19 16:29:38 +02:00
Pēteris Caune
494fd9ffb7
Improve alert summaries in ping log 2019-11-19 15:29:38 +02:00
Pēteris Caune
84bc6e7b2c
Fix typo. 2019-11-14 16:30:07 +02:00
Pēteris Caune
2b4de95141
Cleaner MS Teams setup illustrations. 2019-11-14 15:34:01 +02:00
Pēteris Caune
dc84b7be01
Add Microsoft Teams integration. Fixes #135 2019-11-14 15:19:40 +02:00
Pēteris Caune
046a643b13
Add python 3.8 to .travis.yml -- let's see if it will work... 2019-11-07 14:18:45 +02:00
Pēteris Caune
9cbd3bfc5a
In monthly reports, no downtime stats for the current month (month has just started) 2019-11-06 10:41:14 +02:00
Pēteris Caune
052700a642
Make log events fit better on mobile screens. 2019-11-05 10:45:39 +02:00
Pēteris Caune
87495a74c6
Update changelog. 2019-11-05 09:57:53 +02:00
Pēteris Caune
05855c1c69
Make the "Details" screen fit better on mobile screens. 2019-11-05 09:53:22 +02:00
Pēteris Caune
7904908625
Fix footer height on mobile. 2019-11-05 09:52:58 +02:00
Pēteris Caune
a464154151
On mobile, don't show the "Last Ping" column, but show the gear (Details) button. Fixes #286 2019-11-05 09:52:32 +02:00
Pēteris Caune
7db11fa7aa
Fix the senddeletionnotices command to take into account the new default SMS limit. 2019-10-30 22:12:25 +02:00
Pēteris Caune
c13ec18a27
5 SMS & WhatsApp sends/mo for free plans 2019-10-30 18:31:10 +02:00
Pēteris Caune
2848076d87
Update changelog for 1.10.0 release 2019-10-21 15:04:26 +03:00
Pēteris Caune
3f36d31cde
Display the error field in notifications admin list view, don't load all checks in details view. 2019-10-18 17:22:50 +03:00
Pēteris Caune
66a6de70c0
Send email notification when monthly SMS sending limit is reached. Fixes #292 2019-10-18 17:15:02 +03:00
Pēteris Caune
488ab2cce7
Add a "Create a Copy" function for cloning checks Fixes #288 2019-10-18 12:03:46 +03:00
Pēteris Caune
a5827c6458
Add link to borgmatic in the "Third-Party Resources" page 2019-10-17 11:49:10 +03:00
Pēteris Caune
82fb4ddece
Update OpsGenie logo 2019-10-14 21:14:36 +03:00
Pēteris Caune
01fc8e423b
Update OpsGenie screenshots. 2019-10-14 20:47:56 +03:00
Pēteris Caune
1dea8b6050
Add support for OpsGenie EU region. Fixes #294 2019-10-14 20:31:25 +03:00
Pēteris Caune
4625196ded
Autofocus the email field in the signup form, and submit on enter key 2019-10-12 20:22:28 +03:00
Pēteris Caune
163b020116
Signup form sets the "auto-login" cookie to avoid an extra click during first login 2019-10-12 20:14:57 +03:00
Pēteris Caune
2bb769f7bb
Send monthly reports on 1st of every month, not randomly during the month 2019-10-12 20:07:09 +03:00
Pēteris Caune
391921d8af
Revert deterministic username generation feature – it causes problems when users change their email address. See #290 for details. 2019-10-12 11:37:06 +03:00
Pēteris Caune
6cd4e494e8
Add go example to "manage.py pygmentize" command.
Make sure the Go snippet shows up in the welcome page and also in the check details page.
2019-10-07 15:10:36 +03:00
Pēteris Caune
ad731dfe0e
Merge pull request #293 from omurbekjk/feature/golang-example-added-for-code-snippets
feature: golang code snippet added
2019-10-07 15:00:08 +03:00
omurbekjk
fbc217ef35 feature: golang http get request changes to head 2019-10-07 16:55:09 +06:00
omurbekjk
3d9261c7c4 feature: golang code snippet added 2019-10-03 17:22:36 +06:00
Pēteris Caune
b0db5181d8
Don't validate plan_id if it has not changed from the old value (when updating payment method). 2019-10-02 17:28:20 +03:00
Pēteris Caune
f9ec5b482f
Upgrade to Django 2.2.6. Fixes #284 2019-10-01 17:17:36 +03:00
Pēteris Caune
41a0871452
Generate usernames as uuid3(const, email). Prevents multiple accts with the same email. Prevent double-clicking the submit button in signup form. Fixes #290 2019-09-30 16:40:45 +03:00
Pēteris Caune
335c73d6a2
Upgrade to psycopg2 2.8.3. Fixes #289 2019-09-30 16:08:37 +03:00
Pēteris Caune
ca5e19fd2d
Don't throw an exception if user's current project is unset. 2019-09-18 14:56:58 +03:00
Pēteris Caune
accdfb637b
Remove PDF invoice generation bits - these are unlikely to ever be useful in the open source version. 2019-09-15 18:39:32 +03:00
Pēteris Caune
34925f2cdf
Django compressor 2.2 -> 2.3 2019-09-12 11:01:45 +03:00
Pēteris Caune
0d2736059d
"Sign Up" link in top nav. 2019-09-12 11:01:21 +03:00
Pēteris Caune
4755e1c9da
Exclude sqlite from Travis builds. There's a Django bug that breaks sqlite (https://code.djangoproject.com/ticket/30754#ticket) and I don't want all builds to fail (and potentially mask other issues) until the upstream bugfix is released. 2019-09-09 14:53:21 +03:00
Pēteris Caune
5d8c5637b6
Wording tweaks 2019-09-09 14:47:05 +03:00
Pēteris Caune
6e4e7d737f
Merge pull request #287 from anymuster2/master
Adjusted Pushover notes for clarity on behaviour
2019-09-09 14:34:31 +03:00
anymuster2
4f2b5772df
Adjusted Pushover notes for clarity on behaviour 2019-09-09 21:10:16 +10:00
Pēteris Caune
7fffb95c96
load staticfiles -> load static 2019-09-09 10:55:38 +03:00
Pēteris Caune
c4bb20e3d5
Fixing typo. cc: #285 2019-09-05 08:17:04 +03:00
Pēteris Caune
69d4932194
Add the "Running in Production" section. cc: #283 2019-09-04 16:36:15 +03:00
Pēteris Caune
0d924f4627
Add the "Last Duration" field in the "My Checks" page. Add "last_duration" attribute to the Check API resource. Fixes #257 2019-09-03 13:46:41 +03:00
Pēteris Caune
a0c7cbdfeb
Update changelog for 1.9.0 2019-09-03 09:27:57 +03:00
Pēteris Caune
60defd6244
Django 2.2.5 2019-09-03 09:25:43 +03:00
Pēteris Caune
93507fcc47
Cleanup in report-body-html.html.
Add a "Send Nag" admin command for easier testing.
2019-09-03 09:24:56 +03:00
Pēteris Caune
339ac5e9d9
After adding a new check redirect to the "Check Details" page. 2019-08-27 16:03:06 +03:00
Pēteris Caune
dfee69584b
Don't show the "Sign Up" link in the login page if registration is closed. Fixes #280 2019-08-26 10:55:41 +03:00
Pēteris Caune
9474006d83
Support informal time zones. 2019-08-22 11:41:08 +03:00
Pēteris Caune
dfd449b101
Three choices in timezone switcher (UTC / check's timezone / browser's timezone). Fixes #278 2019-08-22 11:17:27 +03:00
Pēteris Caune
dd83ec2214
Sort integrations into mostly alphabetic order, add Mattermost. 2019-08-21 09:12:04 +03:00
Pēteris Caune
6fce896e1a
Sort integrations into mostly alphabetic order. 2019-08-21 09:05:43 +03:00
Pēteris Caune
862bafc331
Mattermost integration WIP cc: #276 2019-08-20 22:59:11 +03:00
Pēteris Caune
2489f86b38
Delete customer from Braintree when closing account. 2019-08-19 11:47:36 +03:00
Pēteris Caune
fa16bd4e42
Prepare for 3DS 2 2019-08-18 18:16:37 +03:00
Pēteris Caune
33dece4ad2
Remove stray angle bracket. 2019-08-12 23:37:18 +03:00
Pēteris Caune
72d608902d
Fix JS to construct correct URLs when running from a subdirectory. Fixes #273 2019-08-12 23:29:32 +03:00
Pēteris Caune
4c39aeea83
Make sure account limits are reset when user cancels their subscription. 2019-08-12 16:10:49 +03:00
Pēteris Caune
dde2910c59
Cleanup. 2019-08-12 14:41:50 +03:00
Pēteris Caune
554f76e57a
Icon for Apprise. 2019-08-12 13:47:14 +03:00
Pēteris Caune
f1d7b4b39b
Fix alt text for the apprise icon. 2019-08-12 12:09:57 +03:00
Pēteris Caune
ba886e90cb
Merge pull request #272 from caronc/master
Apprise Integration
2019-08-12 11:58:27 +03:00
Pēteris Caune
a99a009491
Fix typo. 2019-08-11 23:46:27 +03:00
Chris Caron
d70539b397 updated apprise documentation 2019-08-08 21:58:30 -04:00
Chris Caron
86ad70f6d5 improved testing 2019-08-08 21:20:58 -04:00
Chris Caron
b5a03369b6 Apprise Notifications are now a controlled via settings 2019-08-08 20:28:54 -04:00
Chris Caron
c2b1d00422 Apprise Integration 2019-08-07 19:36:06 -04:00
Pēteris Caune
d39a1d5955
Django 2.2.4 2019-08-06 10:36:12 +03:00
Pēteris Caune
033d0ab197
Partial indexes for api_check.alert_after and api_flip.processed fields. 2019-07-20 16:58:41 +03:00
Pēteris Caune
c0d808271e
Add the pruneflips management command. 2019-07-20 12:25:58 +03:00
Pēteris Caune
b37d908879
Optimization: don't instantiate Flip objects in Check.downtimes() 2019-07-20 12:17:00 +03:00
Pēteris Caune
b2ebce6cf9
Show the number of downtimes and total downtime minutes in "Check Details" page. 2019-07-20 11:42:16 +03:00
Pēteris Caune
b7320b1b69
In monthly report, show months in ascending order. Cleanup. 2019-07-20 10:17:00 +03:00
Pēteris Caune
cb2e763e98
Cleanup in Check.outages_by_month() and tests. 2019-07-19 19:42:37 +03:00
Pēteris Caune
1de0ef16f6
Style tweaks. 2019-07-19 17:47:47 +03:00
Pēteris Caune
b74e56a273
Experimental: show the number of outages and total downtime in monthly reports. (#104) 2019-07-19 17:32:39 +03:00
Pēteris Caune
e174e1ef4c
Adding "and other contributors" in the copyright notice. 2019-07-18 22:52:35 +03:00
Pēteris Caune
429a69c2e9
Fancy quotes in whatsapp messages. 2019-07-12 14:55:03 +03:00
Pēteris Caune
e54aca6725
v1.8.0 2019-07-08 13:38:56 +03:00
Pēteris Caune
96c2cdbbb8
More information about read-only API keys in API docs. 2019-07-08 11:35:20 +03:00
Pēteris Caune
cc4f8b639b
Add healthchecks/dashboard to "Third-Party Resources" 2019-07-07 11:57:25 +03:00
Pēteris Caune
77fd0d00e0
Add "desc" back in the readonly API responses, and add "unique_key" field, derived from code. 2019-07-04 19:33:26 +03:00
Pēteris Caune
35b137a8d7
Allow caching CORS responses. 2019-07-04 12:50:01 +03:00
Pēteris Caune
e386ccaa0a
Don't mention whatsapp in the pricing page if it's not enabled in settings. 2019-07-04 09:39:31 +03:00
Pēteris Caune
5ab071ed56
Cleanup. 2019-07-04 09:36:41 +03:00
Pēteris Caune
1f1b1aedca
Don't include ping URLs in API responses when the read-only key is used 2019-07-04 09:36:27 +03:00
Pēteris Caune
3eef3c982f
Django 2.2.3 2019-07-02 14:21:13 +03:00
Pēteris Caune
e0f161157d
Fix prunepings and prunepingsslow, fixes #264 2019-06-24 18:02:36 +03:00
Pēteris Caune
4867fab291
Not using I18N so turning it off. 2019-06-21 13:12:05 +03:00
Pēteris Caune
479208abf0
Webhooks support the $TAGS placeholder 2019-06-04 23:40:08 +03:00
Pēteris Caune
71dd8a31eb
Project's name with capital H 2019-06-04 23:39:50 +03:00
Pēteris Caune
080e44f7ba
Show refunded transactions correctly in the billing history. 2019-06-04 23:38:21 +03:00
Pēteris Caune
3c0b9834e9
Django 2.2.2. Also, 1000th commit, cheers! 2019-06-04 23:37:36 +03:00
Pēteris Caune
40f4adf78b
Add WhatsApp integration (uses Twilio same as the SMS integration) 2019-05-31 13:01:01 +03:00
Pēteris Caune
5f0b02845e
Show check's code instead of full URL on 992px - 1200px wide screens. Fixes #253 2019-05-30 11:52:33 +03:00
Pēteris Caune
9dea24e937
A data migration to convert webhook values to the most recent format. 2019-05-30 00:26:30 +03:00
Pēteris Caune
c7eca1c4a7
Better tests. 2019-05-28 15:35:05 +03:00
Pēteris Caune
d054970b02
Webhooks support PUT method.
.Webhooks can have different request bodies and headers for "up" and "events".
2019-05-28 14:25:29 +03:00
Pēteris Caune
8f6726d1ee
Prevent email clients from opening the one-time login links. Fixes #255 2019-05-21 11:26:55 +03:00
Pēteris Caune
78c9ee3e9e
requests==2.22.0 2019-05-20 12:30:34 +03:00
Pēteris Caune
d5bae3d3d8
Fix the "Integrations" page for when the user has no active project 2019-05-20 12:20:12 +03:00
Pēteris Caune
cdfc9840a7
Source formatted with Black 2019-05-15 14:27:50 +03:00
Pēteris Caune
1b948f4d5a
Show check counts in JSON "badges". Fixes #251 2019-05-15 13:42:38 +03:00
Pēteris Caune
0da7b12f55
Show description in text emails only for the "going down" notifications, and only if the description is not empty. 2019-05-14 16:01:22 +03:00
Pēteris Caune
14f504bd22
Updated plain-text alert body. cc: #252 2019-05-14 15:37:54 +03:00
Pēteris Caune
b528b23996
Fix badges for tags containing special characters. Fixes #240, #237 2019-05-14 14:43:43 +03:00
Pēteris Caune
dd9fbc9e8c
Fixing a too low rate limit I had used while debugging. 2019-05-07 12:26:08 +03:00
Pēteris Caune
253f554591
Looks better without Segoe UI on Windows. 2019-05-07 11:48:24 +03:00
Pēteris Caune
ffa23b6504
Empty meta description for the login page. 2019-05-07 10:53:10 +03:00
Pēteris Caune
90634610bb
Merge pull request #248 from Penagwin/master
Updated Build instructions
2019-05-07 10:15:41 +03:00
Penagwin
8f241f7da9
Update README.md 2019-05-06 13:04:21 -04:00
Pēteris Caune
6040759601
Add the prunetokenbucket management command. 2019-05-05 13:04:32 +03:00
Pēteris Caune
b03e2852ff
Update requirements. 2019-05-05 12:54:16 +03:00
Penagwin
573c0b84d4 Updated Build instructions 2019-05-03 14:55:26 -04:00
Pēteris Caune
824caa7698
v1.7.0 2019-05-02 15:39:40 +03:00
Pēteris Caune
6c53719002
Clicking on project's name in page header always goes to "My Projects" overview. 2019-05-02 15:36:18 +03:00
Pēteris Caune
6327b951d5
Tweak wording. 2019-05-02 14:45:38 +03:00
Pēteris Caune
d02a539a21
Skip the verification step if user is setting up email notifications to their own email address. 2019-05-02 14:38:55 +03:00
Pēteris Caune
44bac9dd12
Include the description in email alerts. Fixes #247 2019-05-02 14:10:10 +03:00
Pēteris Caune
32ee6d4ca9
Fix the "send_alert" admin action. 2019-05-02 14:06:56 +03:00
Pēteris Caune
9f69dcb158
Show the Description section in Check Details screen even if the description is missing. Fixes #246 2019-05-02 13:36:34 +03:00
Pēteris Caune
fcff4b48c6
Fixing markup. 2019-04-29 23:27:46 +03:00
Pēteris Caune
23b197526c
Password strength meter and length check in the "Set Password" form 2019-04-29 23:16:49 +03:00
Pēteris Caune
afaa8767cd
Rate limit login-with-password attempts. 2019-04-26 15:51:10 +03:00
Pēteris Caune
beae8e62b4
Rate limit team invites to 20/day 2019-04-26 09:04:51 +03:00
Pēteris Caune
d299feb420
Salt the ip address before hashing 2019-04-25 21:55:30 +03:00
Pēteris Caune
3b3ae8a82c
2018 -> 2019 in email footer 2019-04-25 21:31:53 +03:00
Pēteris Caune
aaa3b2748e
Rate limiting for the "Log In" emails 2019-04-25 21:28:40 +03:00
Pēteris Caune
76479714a4
Use ripple effect instead of arrows in setup instruction screenshots 2019-04-20 23:01:13 +03:00
Pēteris Caune
1cf08e0048
Update Pagertree setup instructions. 2019-04-20 20:14:19 +03:00
Pēteris Caune
7ce612db6a
Pluralize the "1 check" / "X checks" line in the Integrations page. 2019-04-20 19:18:37 +03:00
Pēteris Caune
ab86580b32
Add "Test!" function in the Integrations page. Fixes #207 2019-04-20 17:55:16 +03:00
Pēteris Caune
2a7129f8c8
Explicit decode_data=False (otherwise, py3.5 passes str, py3.6+ passes bytes). Should fix #242 2019-04-16 10:32:00 +03:00
Pēteris Caune
4ecbe95cee
Merge pull request #241 from evaryont/patch-1
Update README.md
2019-04-16 10:09:08 +03:00
No GUI
e2c41b0c1f
Update README.md
Since anonymous checks aren't created any more, don't document the manage command to clean them up.
2019-04-15 20:12:27 +00:00
Pēteris Caune
d682f79075
Update braintree dropin version. 2019-04-12 18:29:00 +03:00
Pēteris Caune
cdd8e57239
Put bootstrap.css at the top to fix precedence issues. 2019-04-10 18:41:34 +03:00
Pēteris Caune
8802fdcfef
Fix footer overlap. 2019-04-10 18:21:00 +03:00
Pēteris Caune
a4fde44e3a
Can configure the email integration to only report the "down" events. Fixes #231 2019-04-10 17:54:19 +03:00
Pēteris Caune
499720a156
Hide the "Confirmation Needed" notice if email channel verification is turned off. Update changelog. 2019-04-10 12:46:10 +03:00
Pēteris Caune
12f8ffcd80
Django==2.2, pytz==2019.1 2019-04-10 12:09:36 +03:00
Pēteris Caune
e870dca0b5
Don't show the search box if the project has no checks. 2019-04-03 11:07:03 +03:00
Pēteris Caune
9c41cf9732
Hide the search box on small screens. 2019-04-03 11:05:50 +03:00
Pēteris Caune
f750c5e3fc
Unused. 2019-04-02 13:21:17 +03:00
Pēteris Caune
dfc6c9fb1b
Tweak style to match top nav. 2019-04-02 13:16:56 +03:00
Pēteris Caune
0b90bb23ce
Merge base.html and base_project.html 2019-04-02 13:12:29 +03:00
Pēteris Caune
178b0ff95c
Show "Badges" and "Settings" in top navigation. Fixes #234 2019-04-02 11:51:35 +03:00
Pēteris Caune
7c13adbf18
Add the EMAIL_USE_VERIFICATION configuration setting. Fixes #232 2019-04-01 11:16:47 +03:00
Pēteris Caune
143c90674b
Update CHANGELOG for v1.6.0 2019-04-01 10:24:23 +03:00
Pēteris Caune
67800782f8
v1.6.0 2019-04-01 10:22:21 +03:00
Pēteris Caune
0b26956a97
Merge pull request #233 from ken8203/feature/update-croniter
Update croniter to 0.3.29
2019-03-27 09:49:30 +02:00
jaychung
cf16b288cc Update croniter to 0.3.29 2019-03-27 15:23:55 +08:00
Pēteris Caune
73e5c651af
Add Matrix to the landing page. 2019-03-21 15:49:16 +02:00
Pēteris Caune
740cda9507
Icon for Pager Team 2019-03-21 13:01:31 +02:00
Pēteris Caune
d7e977d599
Fix taglines, resize images. 2019-03-21 12:31:41 +02:00
Pēteris Caune
31d4582dc0
Merge pull request #229 from Furchin/master
Add Pager Team integration
2019-03-21 12:24:59 +02:00
Michal Bryc
e3c4fe167a high res pagerteam logo 2019-03-15 16:34:48 -07:00
Michal Bryc
65d9180fb8 high-res setup png 2019-03-15 16:33:52 -07:00
Michal Bryc
9b4c4482cb Always use "Pager Team" and use more descriptive tagline 2019-03-15 16:23:37 -07:00
Michal Bryc
de1369f24e Add missing space 2019-03-14 15:28:19 -07:00
Michal Bryc
53467bd7d4 Add pagerteam tests file which had been missed despite its existence 2019-03-14 14:58:03 -07:00
Michal Bryc
073bcb1f6f Add migration (autogenerated via manage.py makemigrations) 2019-03-14 11:03:52 -07:00
Michal Bryc
97ba6ad6b2 Remove duplicate pagerteam url 2019-03-14 11:03:35 -07:00
Michal Bryc
3a21f4e4c4 Add pager team templates 2019-03-13 22:11:49 -07:00
Michal Bryc
738aa8f1d6 Pager Team integration doesn't need a second image 2019-03-13 22:06:43 -07:00
Michal Bryc
5d368eb24e Clean migration 2019-03-13 22:05:49 -07:00
Michal Bryc
954d80b153 add pagerteam integration view 2019-03-13 22:03:35 -07:00
Michal Bryc
6f1ade98e2 Add more views 2019-03-13 22:03:24 -07:00
Michal Bryc
af685205e6 Update view 2019-03-13 21:02:25 -07:00
Michal Bryc
0adc6497b8 Add PagerTeam integration 2019-03-12 20:38:40 -07:00
Pēteris Caune
945a66ab0a
Management command for sending inactive account notifications 2019-03-13 00:38:34 +02:00
Pēteris Caune
acd55ce7f3
Note about DB_NAME and SQLite. 2019-03-13 00:34:07 +02:00
Pēteris Caune
cfb96304ca
Merge pull request #228 from toliger/env
Improve environment variables support
2019-03-13 00:13:41 +02:00
Timothée Oliger
a0a766b500
Improve environment variables support 2019-03-12 21:57:27 +01:00
Pēteris Caune
620fd0eb98
Matrix icon in checks list, integrations column. 2019-03-11 10:54:52 +02:00
Pēteris Caune
68b818a89b
Add Matrix environment variables to README 2019-03-10 11:50:13 +02:00
Pēteris Caune
212578c378
Fix tests. 2019-03-10 11:49:07 +02:00
Pēteris Caune
e294ae0e9b
Setup instructions for the Matrix integration. 2019-03-09 12:22:31 +02:00
Pēteris Caune
954ca4576b
Improved logic for displaying job execution times in log. Fixes #219 2019-03-01 14:39:44 +02:00
Pēteris Caune
468321fcd7
Typo 2019-03-01 14:36:47 +02:00
Pēteris Caune
37c9856e75
Merge pull request #226 from Furchin/master
Fix typo: "appropriate"
2019-02-28 10:32:10 +02:00
Michal Bryc
a95fec5ceb
Fix typo: "appropriate" 2019-02-27 16:51:40 -08:00
Pēteris Caune
cb1b792d6c
smtpd: get a new db connection for every incoming email. cc: #213 2019-02-27 16:44:03 +02:00
Pēteris Caune
100bc3c5e7
Fix a "invalid time format" in front.views.status_single on Windows hosts. Fixes #224 2019-02-27 16:23:57 +02:00
Pēteris Caune
290d46bc2a
Add maxlength attribute to HTML input=text elements. Fixes #225 2019-02-27 15:55:37 +02:00
Pēteris Caune
f8c0c20d34
Retire HipChat. 2019-02-25 22:31:27 +02:00
Pēteris Caune
f539e99652
Matrix integration WIP. cc: #175 2019-02-22 18:23:36 +02:00
Pēteris Caune
15a853bd8a
Escape asterisks in Slack messages. Fixes #223 2019-02-19 11:11:29 +02:00
Pēteris Caune
63b15d74a5
Fix refreshing of the checks page filtered by tags. Fixes #221 2019-02-15 21:24:05 +02:00
Pēteris Caune
5cbd99cb5c
Add the "desc" field (check's description) to API responses 2019-02-15 12:20:19 +02:00
Pēteris Caune
3230cea1e3
Use DB_HOST, DB_PORT env variables for MySQL configuration. Fixes #220 2019-02-14 10:28:46 +02:00
Pēteris Caune
836ea5afda
Django 2.1.7 2019-02-13 10:48:35 +02:00
Pēteris Caune
c89a0b4e40
Index the api_key fields. 2019-02-04 22:44:35 +02:00
Pēteris Caune
3c4027b788
Quicker admin. 2019-02-04 22:28:04 +02:00
Pēteris Caune
c60a33874f
Quicker admin. 2019-02-04 20:11:45 +02:00
Pēteris Caune
5d9f19d2f9
Update Changelog for v1.5.0 2019-02-04 14:01:10 +02:00
Pēteris Caune
8c4e7ad0b1
Fix title. 2019-02-02 14:17:34 +02:00
Pēteris Caune
e65c29f28f
Merge pull request #217 from timfreund/configure-smtp-with-environment
Allow SMTP configuration via environment variables
2019-02-02 00:34:04 +02:00
Pēteris Caune
b0f4bd3fce
Show "grace" status in the "List of Projects" page. Fix the query for badges in top nav. 2019-02-02 00:08:00 +02:00
Pēteris Caune
62310a5181
Show overall project status in the top navigation menu and in the "Select Project" page. cc: #183 2019-02-01 23:25:12 +02:00
Tim Freund
415ec58b95 Allow SMTP configuration via environment variables
This change allows SMTP configuration via email, but users can
still choose to configure the settings directly in local_settings.py
2019-02-01 14:40:24 -05:00
Pēteris Caune
886643db84
Prefetch projects in profile admin. 2019-02-01 14:33:02 +02:00
Pēteris Caune
ae77f0bbd5
Highlight current project. 2019-02-01 14:24:50 +02:00
Pēteris Caune
1b085a154b
"Don't have an account? Sign Up" line in the login page. 2019-02-01 14:14:55 +02:00
Pēteris Caune
d04f3cc328
Use Project.badge_key for generating badge URLs 2019-01-31 22:51:19 +02:00
Pēteris Caune
0cceeffcd8
Handle the case where user has no projects. 2019-01-31 22:21:20 +02:00
Pēteris Caune
c4c657f5d4
Add "Transfer to Another Project" dialog in check's Details page. 2019-01-31 22:09:46 +02:00
Pēteris Caune
543a8c7c6a
Gear icon always visible. 2019-01-30 16:02:45 +02:00
Pēteris Caune
069bc9b735
Test cases for adding project, removing project and leaving project. 2019-01-29 19:57:18 +02:00
Pēteris Caune
02609ac05e
This query can return duplicates, add distinct() 2019-01-29 19:56:43 +02:00
Pēteris Caune
282c3d39cb
Template tweaks. 2019-01-29 19:27:14 +02:00
Pēteris Caune
4ff1654806
Don't create default projects for invited users. 2019-01-29 19:16:52 +02:00
Pēteris Caune
c1e4595ab2
If user has a single project, _redirect_after_login redirects to it. 2019-01-29 19:05:32 +02:00
Pēteris Caune
f2ae573872
Reduce the usage of request.project. 2019-01-29 16:54:51 +02:00
Pēteris Caune
08810d1fca
Check membership before removing project member. 2019-01-29 16:42:12 +02:00
Pēteris Caune
8dd91b247b
When user has no projects, don't auto-create a project. 2019-01-29 13:19:15 +02:00
Pēteris Caune
ac2a120ee8
"Create New Project" button in "My Projects" page. 2019-01-29 11:41:02 +02:00
Pēteris Caune
6b0d566922
"My Projects" page. 2019-01-29 10:59:10 +02:00
Pēteris Caune
4e6fa38ec6
Users can create and remove Projects -- WIP 2019-01-28 20:09:23 +02:00
Pēteris Caune
6d7942d7f9
Admin improvements. 2019-01-28 13:05:40 +02:00
Pēteris Caune
8623e9c25a
Add github.com/linuxserver/docker-healthchecks to "Third Party Resources" page. 2019-01-26 19:42:15 +02:00
Pēteris Caune
37337054c7
Add "API Access" to the pricing table 2019-01-22 23:53:27 +02:00
Pēteris Caune
87919dbc5a
Account Settings -> Project Settings 2019-01-22 23:52:59 +02:00
Pēteris Caune
28483020bf
Fix mysql configuration in .travis.yml 2019-01-22 18:39:12 +02:00
Pēteris Caune
1a9137c0ac
Set Travis dist to xenial, add py3.7 2019-01-22 18:34:39 +02:00
Pēteris Caune
229e2a3922
Admin improvements. 2019-01-22 18:18:11 +02:00
Pēteris Caune
f35f5893d8
Fix wording in the "Close Account" dialog. 2019-01-22 16:26:58 +02:00
Pēteris Caune
eaf49f2367
Don't switch projects when viewing the "Account Settings" page 2019-01-22 16:23:51 +02:00
Pēteris Caune
b12eb1ee75
Users switch between projects, not between accounts. 2019-01-22 15:58:07 +02:00
Pēteris Caune
b013a92c43
Move project-specific settings to a new "Project Settings" page 2019-01-22 15:44:54 +02:00
Pēteris Caune
64158c83a8
front.views.status uses project_id not user.username 2019-01-22 14:17:59 +02:00
Pēteris Caune
8eedf9d47b
Remove tooltips from channel icons 2019-01-22 13:50:40 +02:00
Pēteris Caune
14c67bdaa9
Fix team display in admin. 2019-01-22 11:21:42 +02:00
Pēteris Caune
7ecd0b606d
Quicker prunenotifications, skip checks with low n_pings values. 2019-01-22 11:09:41 +02:00
Pēteris Caune
a144bc762d
Fix the team member list in Account Settings > Team Access 2019-01-22 10:53:44 +02:00
Pēteris Caune
d52d292889
Remove Member.team_id (use Member.project_id instead) 2019-01-22 10:36:41 +02:00
Pēteris Caune
70e467fb71
Use a subquery in Profile.checks_from_all_projects, saves one query 2019-01-21 22:00:45 +02:00
Pēteris Caune
d403260e9b
Preload Profile.current_project, saves one query per request. 2019-01-21 21:59:50 +02:00
Pēteris Caune
3c1964b493
Prepare for the removal of Member.team_id 2019-01-21 21:35:32 +02:00
Pēteris Caune
a5a45db7a8
Fix the sorting of projects in the top navigation dropdown menu. 2019-01-19 19:13:26 +02:00
Pēteris Caune
c42a854e75
Fix project names in the top navigation dropdown menu, avoid duplicates. 2019-01-19 19:08:06 +02:00
Pēteris Caune
e98bd42438
Fix project names in the top navigation dropdown menu. 2019-01-19 18:51:29 +02:00
Pēteris Caune
664aad916a
Remove Profile.team_name (use Project.name instead) and Profile.current_team (use Profile.current_project instead) 2019-01-19 17:56:16 +02:00
Pēteris Caune
77e3212956
Prepare for the removal of Profile.current_team_id 2019-01-19 17:24:54 +02:00
Pēteris Caune
0994006603
Drop Check.user_id and Channel.user_id (obsolete, using project_id now) 2019-01-18 17:24:02 +02:00
Pēteris Caune
512c67a8f9
Fix Trello testcase. 2019-01-18 16:57:49 +02:00
Pēteris Caune
95dff3e799
Fix add_trello: set the Channel.project field. 2019-01-18 16:50:47 +02:00
Pēteris Caune
caf6668478
Prepare for the removal of Check.user_id and Channel.user_id 2019-01-18 16:44:09 +02:00
Pēteris Caune
e1b999e83a
Prepare for the removal of Check.user_id 2019-01-18 14:59:01 +02:00
Pēteris Caune
654516412e
Don't show Profile.api_key in admin. 2019-01-17 16:34:31 +02:00
Pēteris Caune
c08f02ab7f
Drop Profile.api_key and Profile.api_key_readonly (both are stored with Project now) 2019-01-17 16:26:45 +02:00
Pēteris Caune
249cb8b82d
Updated Slack logo. 2019-01-17 16:16:33 +02:00
Pēteris Caune
c16e9dc4fe
Prepare for the removal of Profile.api_key 2019-01-17 16:02:57 +02:00
Pēteris Caune
fc18652afa
Remove a hchk.io reference from docs. 2019-01-17 15:42:23 +02:00
Pēteris Caune
e2d2665edf
Prepare for the removal of Check.user_id 2019-01-15 21:36:01 +02:00
Pēteris Caune
fba8806e97
Prepare for the removal of Member.team_id 2019-01-14 22:33:28 +02:00
Pēteris Caune
f357cd3305
Prepare for removing Check.user_id, Channel.user_id, Profile.current_team_id 2019-01-14 21:13:57 +02:00
Pēteris Caune
82b644ae0a
Project.num_checks_available() method. 2019-01-14 12:55:54 +02:00
Pēteris Caune
16bff94fab
Use BaseTestCase in test_login, less repetition 2019-01-14 12:54:42 +02:00
Pēteris Caune
965998df18
Fix tests. 2019-01-14 11:28:10 +02:00
Pēteris Caune
34e54cca42
project_id must be not null. 2019-01-14 11:00:14 +02:00
Pēteris Caune
46c00e31a6
Use Project.badge_key in api.views.badge 2019-01-12 22:28:23 +02:00
Pēteris Caune
b5df5b3c6e
Project.badge_key must be not null and unique. 2019-01-12 22:08:43 +02:00
Pēteris Caune
d102f10a2d
Add Project.badge_key so we can preserve the current badge URLs 2019-01-12 21:56:56 +02:00
Pēteris Caune
6b7f212c8a
Use the api keys from project, not user profile 2019-01-12 21:34:45 +02:00
Pēteris Caune
1c69cf7f89
Project model. cc: #183 2019-01-12 16:40:21 +02:00
Pēteris Caune
b4635c69e7
Django 2.1.5 2019-01-07 11:03:46 +02:00
Pēteris Caune
5edcd42033
Add the "Email Settings..." dialog and the "Subject Must Contain" setting 2019-01-04 16:07:11 +02:00
Pēteris Caune
4f2930bb05
Remove Ping.start and Ping.fail fields 2019-01-04 13:25:58 +02:00
Pēteris Caune
20df5843e3
Don't update Ping.start and Ping.fail fields (use Ping.kind instead) 2019-01-04 12:57:29 +02:00
Pēteris Caune
99d46a0ca8
Use Ping.kind instead of Ping.start and Ping.fail when displaying logs and ping details. 2019-01-04 12:07:27 +02:00
Pēteris Caune
7480eca2a5
Add Ping.kind field 2019-01-04 11:24:53 +02:00
Pēteris Caune
be4c4f7a26
set Check.user to not null, add uniqueness constraint to Check.code 2019-01-02 09:58:54 +02:00
Pēteris Caune
179b085df4
Move Check.send_alert() to Flip.send_alerts() 2018-12-30 11:55:09 +02:00
Pēteris Caune
0bdb0213e6
Resize and pngquant the integration icons 2018-12-29 20:25:03 +02:00
Pēteris Caune
c225a67a3a
Index Channel.code 2018-12-26 01:16:09 +02:00
Pēteris Caune
5067014a19
Prefetch Channel objects, avoid running a query per check. 2018-12-26 00:21:56 +02:00
Pēteris Caune
da399e71b7
Remove unused bits. 2018-12-25 23:13:02 +02:00
Pēteris Caune
5dc4a879e4
Add MyPropertyOffice/node-hchk to docs - resources. 2018-12-25 21:20:21 +02:00
Pēteris Caune
67d72758be
v1.4.0 2018-12-25 17:51:40 +02:00
Pēteris Caune
eb4e579a71
Update docs with the "/start" endpoint. 2018-12-25 17:39:40 +02:00
Pēteris Caune
b3e290b284
Show elapsed times in ping log 2018-12-25 13:01:49 +02:00
Pēteris Caune
e76329a364
Admin tweaks 2018-12-24 19:50:09 +02:00
Pēteris Caune
eee6fc12f4
Overwrite Check.last_start value 2018-12-21 14:02:25 +02:00
Pēteris Caune
fe04429fad
Don't update last_ping_was_fail, it is not read anywhere anymore. 2018-12-21 13:17:00 +02:00
Pēteris Caune
cc40793fc7
Clear Check.alert_after when pausing a check. 2018-12-21 11:31:00 +02:00
Pēteris Caune
2f4b373e12
More test cases. Check.is_down() is redundant, removing. 2018-12-21 11:25:49 +02:00
Pēteris Caune
93405cc286
Tag labels in "My Checks" page and SVG badges should ignore the "started" state. 2018-12-20 19:19:46 +02:00
Pēteris Caune
5f9ebb178c
Rename "Check.get_alert_after" to a now more fitting "Check.going_down_after" 2018-12-19 21:57:48 +02:00
Pēteris Caune
0b6c317956
Fix Check.is_down(), add tests. 2018-12-19 18:27:58 +02:00
Pēteris Caune
b803d877d0
Fix formatting. 2018-12-18 23:05:37 +02:00
Pēteris Caune
481848a749
Add "/ping/<code>/start" API endpoint 2018-12-18 22:57:12 +02:00
Pēteris Caune
25e48f1b9f
croniter.is_valid() throws exceptions for some bad inputs, so must use try ... except 2018-12-14 18:58:35 +02:00
Pēteris Caune
a402dce293
Validate and reject cron schedules with six components 2018-12-14 15:21:02 +02:00
Pēteris Caune
cf08f54c30
Use timezone-aware datetimes with croniter, avoid conversions to and from naive datetimes. This avoids ambiguities around DST transitions and properly solves #196 2018-12-14 12:24:12 +02:00
Pēteris Caune
e21801f44e
Admin tweaks. 2018-12-14 11:12:13 +02:00
Pēteris Caune
1a9f7e17f8
Cancelling a plan clears out Subscription.plan_name 2018-12-14 11:05:57 +02:00
Pēteris Caune
a8c102f799
test_update_timeout: test if a Flip object gets created 2018-12-14 10:46:22 +02:00
Pēteris Caune
925d34daad
Update Check.status field when user edits timeout & grace settings 2018-12-13 16:53:26 +02:00
Pēteris Caune
11f65ff7aa
Optimize db query in sendalerts 2018-12-12 19:04:37 +02:00
Pēteris Caune
828bc52f80
Admin tweaks 2018-12-12 18:16:28 +02:00
Pēteris Caune
58a34ae061
Admin tweaks 2018-12-12 18:12:04 +02:00
Pēteris Caune
a3ddf0ddef
Use email instead of team name in "<user> invites you to their account" emails. Unfortunately the team name is being abused for spam. 2018-12-12 18:09:41 +02:00
Pēteris Caune
837cac300d
Use email instead of team name in "<user> invites you to their account" emails. Unfortunately the team name is being abused for spam. 2018-12-12 16:35:31 +02:00
Pēteris Caune
5be6c403a4
Flip model, for tracking status changes of the Check objects. 2018-12-10 17:51:42 +02:00
Pēteris Caune
440a143dd6
Add CORS support to API endpoints. Fixes #208 2018-12-06 17:36:20 +02:00
Pēteris Caune
b9a24a21e7
Remove the Zendesk integration (unfinished, could not get it to work acceptably) 2018-11-30 22:07:37 +02:00
Pēteris Caune
19ef8b3f7b
Allow simultaneous access to dashboards from different teams 2018-11-29 15:00:01 +02:00
Pēteris Caune
c2f200fa02
Allow simultaneous access to checks from different teams 2018-11-29 13:51:25 +02:00
Pēteris Caune
d36d4fb543
Additional python usage examples 2018-11-29 11:11:44 +02:00
Pēteris Caune
5aba9d6196
Fix after-login redirects to "Check Details" and other pages. 2018-11-28 22:06:12 +02:00
Pēteris Caune
b081631e90
Fix after-login redirects for users landing in the "Add Pushover" page 2018-11-28 21:45:54 +02:00
Pēteris Caune
fb45b67892
Set Pushover alert priorities for "down" and "up" events separately. Fixes #204 2018-11-28 21:40:46 +02:00
Pēteris Caune
2e6d050656
Switch from selectize.js to bootstrap-select (for more versatility) 2018-11-28 20:13:24 +02:00
Pēteris Caune
eaba39d99b
Tweak HTML markup in alert emails for Gmail 2018-11-27 18:37:53 +02:00
Pēteris Caune
c4fabc55e8
Fix after-login redirects for users landing in the "Add Slack" page 2018-11-26 17:32:31 +02:00
Pēteris Caune
bf1395801f
Fix after-login redirects for users landing in the "Add Slack" page 2018-11-26 17:32:23 +02:00
Pēteris Caune
a7061fe6a5
Add "Get a List of Existing Integrations" API call 2018-11-21 20:21:04 +02:00
Pēteris Caune
21de50d84e
Add Channel.name field, users can now name integrations. 2018-11-20 23:31:15 +02:00
Pēteris Caune
c78ed91335
Make "API Reference" more prominent in Docs 2018-11-20 12:15:32 +02:00
Pēteris Caune
01d94176dd
Tweak HTML and CSS in alert emails. 2018-11-14 11:04:48 +02:00
Pēteris Caune
601fcb7cf7
Update package versions in requirements.txt 2018-11-14 10:29:47 +02:00
Pēteris Caune
8ad3b0d537
Simplify inline scripts for easier CSP 2018-11-12 10:37:31 +02:00
Pēteris Caune
472c6424e9
Self-host slack button graphics 2018-11-12 10:21:21 +02:00
Pēteris Caune
66bc5cd7c2
Validate channel identifiers as UUIDs 2018-11-10 11:42:31 +02:00
Pēteris Caune
d064112c16
Template for the unsubscribe confirmation form. 2018-11-09 22:17:45 +02:00
Pēteris Caune
b3c22dcfd2
A workaround for some email agents automatically opening "Unsubscribe" links 2018-11-09 22:12:11 +02:00
Pēteris Caune
5f59d97d21
Fix tests. 2018-11-08 12:27:24 +02:00
Pēteris Caune
491999db00
Update docs. 2018-11-08 12:25:23 +02:00
Pēteris Caune
8889cfe993
Add "channels" attribute to the Check API resource 2018-11-08 12:13:18 +02:00
Pēteris Caune
16d78db72e
Refactoring and a testcase for channels=None 2018-11-08 11:59:04 +02:00
Pēteris Caune
e866d63ca4
Split into smaller separate testcases. 2018-11-08 11:38:55 +02:00
Pēteris Caune
def1a12a4a
Merge pull request #199 from muff1nman/add-specific-channel
Allow specific channel uuid to be specified in create/update check api
2018-11-08 11:26:45 +02:00
Andrew DeMaria
824729707e Allow specific channel uuid to be specified in create/update check api 2018-11-07 15:17:20 -07:00
Pēteris Caune
0ece2664ac
Show a warning when running with DEBUG=True. Fixes #189 2018-11-06 10:48:59 +02:00
Pēteris Caune
5ef67e8bbf
Remove Profile.bill_to field. 2018-11-06 10:19:59 +02:00
Pēteris Caune
63c70d67c6
requests 2.20.0 2018-10-30 08:57:43 +02:00
Pēteris Caune
432e592e44
Add read-only API key support 2018-10-29 21:44:34 +02:00
Pēteris Caune
182f9e1109
Refactor API key checking code 2018-10-29 18:34:58 +02:00
Pēteris Caune
887c4d534a
add "minLength" support to the jsonschema validator 2018-10-29 17:13:45 +02:00
Pēteris Caune
0c6dcfa766
Responsive tweaks. 2018-10-29 14:21:47 +02:00
Pēteris Caune
40c83e3cba
Add a search box in the "My Checks" page. 2018-10-29 14:01:03 +02:00
Pēteris Caune
c57a9dcbc4
Omit "Details..." link (testing if it's triggering Gmail warnings) 2018-10-29 12:16:50 +02:00
Pēteris Caune
58cfaaa527
Don't send monthly reports to inactive accounts (no pings in 6 months) 2018-10-24 11:30:16 +03:00
Pēteris Caune
9f02371d6a
Report unsubscribe link works with POST. Include "X-Bounce-Url" header in report emails. 2018-10-24 10:06:51 +03:00
Pēteris Caune
df86fd29b3
During DST transition, handle ambiguous dates as pre-transition. Fixes #196 2018-10-23 12:35:07 +03:00
Pēteris Caune
b4e53431cd
Obsolete – we don't create anonymous checks any more. 2018-10-23 12:32:31 +03:00
Pēteris Caune
b9a81ad382
Add "List-Unsubscribe" header to alert and report emails 2018-10-23 11:26:13 +03:00
Pēteris Caune
c4543bce58
Load settings from environment variables. Fixes #187 2018-10-22 17:25:58 +03:00
Pēteris Caune
cbb5e8cae5
Shorter, simpler telegram message. 2018-10-22 09:49:35 +03:00
Pēteris Caune
fb1fb4992e
v1.2.0 2018-10-20 19:47:01 +03:00
Pēteris Caune
dd342f3d30
"Log In" -> "Sign In" 2018-10-20 19:24:21 +03:00
Pēteris Caune
288a57a4b1
Better name & description for Trello cards. 2018-10-20 19:09:40 +03:00
Pēteris Caune
362a43dae7
Merge pull request #193 from erickeller/master
update Readme with pip install dependencies
2018-10-19 21:17:19 +03:00
erickeller
1dc9bcf798
update Readme with pip install dependencies
pip install will fail when you cannot compile some of the dependencies.
one is gcc the other is the Python.h


```
Building wheels for collected packages: rcssmin, rjsmin
  Running setup.py bdist_wheel for rcssmin ... error
  Complete output from command /home/ubuntu/webapps/hc-venv/bin/python3 -u -c "import setuptools, tokenize;__file__='/tmp/pip-install-ipfho29k/rcssmin/setup.py';f=getattr(tokenize, 'open', open)(__file__);code=f.read().replace('\r\n', '\n');f.close();exec(compile(code, __file__, 'exec'))" bdist_wheel -d /tmp/pip-wheel-u0q6mggl --python-tag cp36:
  running bdist_wheel
  running build
  running build_py
  creating build
  creating build/lib.linux-x86_64-3.6
  copying ./rcssmin.py -> build/lib.linux-x86_64-3.6
  running build_ext
  building '_rcssmin' extension
  creating build/temp.linux-x86_64-3.6
  x86_64-linux-gnu-gcc -pthread -DNDEBUG -g -fwrapv -O2 -Wall -g -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -fPIC -DEXT_MODULE=_rcssmin -UEXT_PACKAGE -I_setup/include -I/usr/include/python3.6m -I/home/ubuntu/webapps/hc-venv/include/python3.6m -c rcssmin.c -o build/temp.linux-x86_64-3.6/rcssmin.o
  unable to execute 'x86_64-linux-gnu-gcc': No such file or directory
  error: command 'x86_64-linux-gnu-gcc' failed with exit status 1
  
  ----------------------------------------
  Failed building wheel for rcssmin
  Running setup.py clean for rcssmin
  Running setup.py bdist_wheel for rjsmin ... error
  Complete output from command /home/ubuntu/webapps/hc-venv/bin/python3 -u -c "import setuptools, tokenize;__file__='/tmp/pip-install-ipfho29k/rjsmin/setup.py';f=getattr(tokenize, 'open', open)(__file__);code=f.read().replace('\r\n', '\n');f.close();exec(compile(code, __file__, 'exec'))" bdist_wheel -d /tmp/pip-wheel-axnaq3w9 --python-tag cp36:
  running bdist_wheel
  running build
  running build_py
  creating build
  creating build/lib.linux-x86_64-3.6
  copying ./rjsmin.py -> build/lib.linux-x86_64-3.6
  running build_ext
  building '_rjsmin' extension
  creating build/temp.linux-x86_64-3.6
  x86_64-linux-gnu-gcc -pthread -DNDEBUG -g -fwrapv -O2 -Wall -g -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -fPIC -DEXT_MODULE=_rjsmin -UEXT_PACKAGE -I_setup/include -I/usr/include/python3.6m -I/home/ubuntu/webapps/hc-venv/include/python3.6m -c rjsmin.c -o build/temp.linux-x86_64-3.6/rjsmin.o
  unable to execute 'x86_64-linux-gnu-gcc': No such file or directory
  error: command 'x86_64-linux-gnu-gcc' failed with exit status 1
```

```
  Running setup.py bdist_wheel for rjsmin ... error
  Complete output from command /home/ubuntu/webapps/hc-venv/bin/python3 -u -c "import setuptools, tokenize;__file__='/tmp/pip-install-cfntw7bo/rjsmin/setup.py';f=getattr(tokenize, 'open', open)(__file__);code=f.read().replace('\r\n', '\n');f.close();exec(compile(code, __file__, 'exec'))" bdist_wheel -d /tmp/pip-wheel-ytqxu9_b --python-tag cp36:
  running bdist_wheel
  running build
  running build_py
  creating build
  creating build/lib.linux-x86_64-3.6
  copying ./rjsmin.py -> build/lib.linux-x86_64-3.6
  running build_ext
  building '_rjsmin' extension
  creating build/temp.linux-x86_64-3.6
  x86_64-linux-gnu-gcc -pthread -DNDEBUG -g -fwrapv -O2 -Wall -g -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -fPIC -DEXT_MODULE=_rjsmin -UEXT_PACKAGE -I_setup/include -I/usr/include/python3.6m -I/home/ubuntu/webapps/hc-venv/include/python3.6m -c rjsmin.c -o build/temp.linux-x86_64-3.6/rjsmin.o
  In file included from rjsmin.c:18:0:
  _setup/include/cext.h:34:10: fatal error: Python.h: No such file or directory
   #include "Python.h"
            ^~~~~~~~~~
  compilation terminated.
```
2018-10-19 18:21:41 +02:00
Pēteris Caune
1ea7902c86
Fix URL and icon 2018-10-18 12:24:43 +03:00
Pēteris Caune
e4d0103544
Trello integration WIP 2018-10-18 12:20:33 +03:00
Pēteris Caune
2078b45ad6
When filtering by tags, put the selected tags in the query string. Fixes #191 2018-10-12 12:52:48 +03:00
Pēteris Caune
ba1357bcdc
It is now OK to autocomplete email fields. 2018-10-12 11:54:39 +03:00
Pēteris Caune
9214265136
Separate sign up and login forms. 2018-10-12 10:55:15 +03:00
Pēteris Caune
371eebe1f2
Disable autocomplete for email fields. 2018-10-11 11:32:02 +03:00
Pēteris Caune
4acd6a16e8
Login form: rename the email box to "identity" to avoid some auto-signup bots 2018-10-10 09:53:42 +03:00
Pēteris Caune
a58ce791c0
Improved layout & style, fixed hamburger menu in login page. 2018-10-09 16:12:02 +03:00
Pēteris Caune
160aa191c8
"Docs > Third-Party Resources" page. Fixes #174, #68 2018-10-08 14:47:25 +03:00
Pēteris Caune
59857a69c0
"Docs > Third-Party Resources" page. Fixes #174, #68 2018-10-08 14:47:15 +03:00
Pēteris Caune
e5285b0816
Django 2.1.2 2018-10-04 09:29:16 +03:00
Pēteris Caune
621e04e845
Change "foo@example.org is not available" message to "foo@example.org is already registed". 2018-10-03 10:52:51 +03:00
Pēteris Caune
75fa27436e
Document "manage.py smtpd" in README, fixes #188 2018-10-02 15:15:09 +03:00
Pēteris Caune
cf37439877
Timezones were missing in Details page, "Change Schedule" dialog. Fixed. 2018-10-02 14:37:58 +03:00
Pēteris Caune
9e9c504e2d
Update plan names, nicer "Change Plan" form. 2018-09-25 13:12:21 +03:00
Pēteris Caune
7cac2d91bc
Add "What Can I Monitor" section in the Welcome page. 2018-09-11 20:57:32 +03:00
Pēteris Caune
1b962efe41
New styling for integrations on the Welcome page. 2018-09-10 21:58:24 +03:00
Pēteris Caune
3a8056f0e2
Showcase the "Check Details" page and the badges 2018-09-09 23:44:55 +03:00
Pēteris Caune
d77a1a7c60
v1.1.0 2018-08-20 20:19:46 +03:00
Pēteris Caune
d2939ac9af
Include check's description in Slack messages. 2018-08-20 20:17:23 +03:00
Pēteris Caune
78aca869c5
"Details..." instead of "Show Log..." in emails. 2018-08-20 19:30:36 +03:00
Pēteris Caune
1b664a6b54
Wider content area in emails 2018-08-20 19:29:21 +03:00
Pēteris Caune
7046e2410c
Checks have a "Description" field. Fixes #182 2018-08-20 18:16:00 +03:00
Pēteris Caune
0a50962f2b
Drop the trunc template filter, Django has a built in truncatechars that does the same thing. 2018-08-20 13:50:40 +03:00
Pēteris Caune
fdbe733df3
C# usage example. Fixes #127 2018-08-20 12:39:57 +03:00
Pēteris Caune
1a5478dee9
Starting a changelog. Fixes #177 2018-08-20 12:09:30 +03:00
Pēteris Caune
3c1f6baf24
django-compressor 2.2 2018-08-20 11:57:13 +03:00
Pēteris Caune
367d45fcef
pytz 2018.5 2018-08-20 11:54:53 +03:00
Pēteris Caune
20f611a075
requests 2.19.1 2018-08-20 11:50:31 +03:00
Pēteris Caune
bee1cbca80
Better ellipsizing of long log lines. 2018-08-19 20:06:42 +03:00
Pēteris Caune
7f6d6dcea1
Cleanup. 2018-08-19 19:27:10 +03:00
Pēteris Caune
7e56eb883e
Fetch ping details using HTTP GET, not HTTP POST. 2018-08-19 18:18:25 +03:00
Pēteris Caune
97b3b52df5
Testcases for the new code. 2018-08-19 18:08:57 +03:00
Pēteris Caune
3ecd6bd422
Can toggle integrations in Details page by clicking on them. 2018-08-19 16:02:17 +03:00
Pēteris Caune
8ab8e15c4e
Update document.title 2018-08-19 13:33:21 +03:00
Pēteris Caune
8fa9a6f3f6
Fix local time / UTC switcher (it was getting reset on auto-updates). 2018-08-19 13:11:35 +03:00
Pēteris Caune
3021e1c944
Fix redirects. 2018-08-19 13:11:00 +03:00
Pēteris Caune
ecba1eb40b
More efficient log updates. 2018-08-19 12:52:51 +03:00
Pēteris Caune
a883fe38aa
Log auto-refreshes in the details page. 2018-08-19 12:32:03 +03:00
Pēteris Caune
04fede0897
Auto-refresh current status in log page. 2018-08-17 20:53:50 +03:00
Pēteris Caune
8b01acefe2
Show 20 most recent pings by default. 2018-08-17 18:20:15 +03:00
Pēteris Caune
8e7b05f96e
Gear icon links to Log page directly 2018-08-17 15:37:38 +03:00
Pēteris Caune
458c4cfeeb
Hook up buttons in the log page. 2018-08-17 15:09:36 +03:00
Pēteris Caune
ea8e08acd2
Log page redesign WIP 2018-08-17 11:40:45 +03:00
Pēteris Caune
bf69e09dff
psycopg 2.7.5 2018-08-17 11:40:02 +03:00
Pēteris Caune
714b1c29c9
"how much time to wait" -> "how long to wait" 2018-08-17 11:39:12 +03:00
Pēteris Caune
5ffc3088f3
staticfiles -> static, and some cleanup 2018-08-04 19:43:00 +03:00
Pēteris Caune
d661839e32
Don't serialize POST payload to JSON, Django's TestClient will do that for us. 2018-08-04 19:07:40 +03:00
Pēteris Caune
ccf8aa55f0 Django 2.1 2018-08-03 17:10:35 +03:00
Pēteris Caune
bda2941001
Right-align the dropdown menu so it fits on 1366x768 screen. Fixes #181 2018-07-24 12:19:31 +03:00
Pēteris Caune
33d8a1505c
Merge pull request #178 from danielfaulknor/python-3.6
Fix for Python 3.6
2018-06-28 17:53:45 +03:00
Dan Faulknor
8265ac5a97
Fix for Python 3.6 2018-06-29 00:26:39 +12:00
Pēteris Caune
1c71010a41
Less repetition, smaller size of "My Checks" HTML 2018-06-15 11:40:54 +03:00
Pēteris Caune
87dabf881a
Title for the usage examples dialog. 2018-06-15 10:59:48 +03:00
Pēteris Caune
123013c67d
"Show Log" button in "Last Ping" dialog. 2018-06-15 10:52:38 +03:00
Pēteris Caune
e4150e8514
Settings > Badges page shows badges from all teams user has access to. 2018-06-15 01:07:52 +03:00
Pēteris Caune
9cbd0138da
Demo checks shown on welcome page are not saved to database. User's first check is created when creating the user. 2018-06-14 23:42:39 +03:00
Pēteris Caune
a4855e1900
Rename form field "email" to "identity" to hopefully avoid some auto-form-fill spam. 2018-06-14 16:29:15 +03:00
Pēteris Caune
3241ea8c00
Update icons and screenshots on the Welcome page. 2018-06-11 23:02:14 +03:00
Pēteris Caune
dc0a5cb8d8
Filtering by tags works properly again. 2018-06-11 22:26:55 +03:00
Pēteris Caune
114d1a830b
Show up to 10 channels as icons. 2018-06-11 21:47:47 +03:00
Pēteris Caune
5cf6f1b51e
Merge Check.get_status() and Check.in_grace_period() into one.
This avoids duplicate calls to Check.get_grace_start() in several places.
2018-06-11 19:05:18 +03:00
Pēteris Caune
0e8226b5d7
Optimize /checks/status: load and parse the template once, not N times. 2018-06-11 18:32:05 +03:00
Pēteris Caune
0cfc945a11
Re-adding mail icon 2018-06-11 18:28:09 +03:00
Pēteris Caune
dd5a690b99
"My Checks" page uses the same markup for desktop and mobile. 2018-06-11 17:25:51 +03:00
Pēteris Caune
f119883d67
Allow check names to wrap at underscores 2018-06-11 16:51:06 +03:00
Pēteris Caune
67363abe4f
Use icon font for channel icons. 2018-06-11 15:54:24 +03:00
Pēteris Caune
b6d47eb3b5
Generate less HTML to show the channel grid. 2018-06-11 12:40:20 +03:00
Pēteris Caune
639249a395
/checks/status/ sends smaller payload. 2018-06-11 12:02:00 +03:00
Pēteris Caune
8d6a694711
Performance tweaks, avoid slow repaints when showing tooltips 2018-06-11 11:31:28 +03:00
Pēteris Caune
422de02ca9
oops 2018-06-11 11:30:05 +03:00
Pēteris Caune
83a2ff17e6
Toggle integrations on/off on "My Checks" page. 2018-06-10 23:19:25 +03:00
Pēteris Caune
0b3030311c
Rename pushover.png -> po.png to match the Channel.kind value 2018-06-08 20:04:38 +03:00
Pēteris Caune
9ae4235c9b
"My Checks" page: show the number of failed checks in the page title. 2018-06-04 22:31:12 +03:00
Pēteris Caune
464d05c99f
"Signalling a Failure" section in docs. (cc: #151) 2018-05-31 14:28:28 +03:00
Pēteris Caune
dfcf7aafbe
Check.in_grace_period() looks at last_ping_was_fail flag. 2018-05-31 11:55:29 +03:00
Pēteris Caune
5f908a01e4
When we don't recognize a message from Telegram, respond with 200 OK so Telegram doesn't keep retrying. 2018-05-30 15:24:12 +03:00
Pēteris Caune
6bf6ab6479
Show the number of remaining available checks under "Add Check" button
when 10 or less remaining.
2018-05-30 14:52:11 +03:00
Pēteris Caune
7ced981d45
Remove obsolete signature checking code in accounts.views.unsubscribe_reports 2018-05-25 23:38:02 +03:00
Pēteris Caune
bf1af1c0d5
Wording tweaks. 2018-05-25 20:34:08 +03:00
Pēteris Caune
fd367b42da
Always show failed checks first. Fixes #173 2018-05-25 20:20:58 +03:00
Pēteris Caune
ebfae7e848
"Django 2" in README 2018-05-17 19:45:16 +03:00
Pēteris Caune
cdf39a88eb
Cleanup. 2018-05-17 19:43:39 +03:00
Pēteris Caune
3fc84ca0ff
Foundation for "fail" pings (cc: #151) 2018-05-17 19:41:13 +03:00
Pēteris Caune
7f4a568bea
Tweak more meta descriptions and keywords. 2018-05-17 14:32:12 +03:00
Pēteris Caune
6cef65e0d2
Tweak meta descriptions, keywords and copy 2018-05-17 14:13:01 +03:00
Pēteris Caune
79d940aefb
Pricing page defaults to the annual plans. 2018-05-15 21:49:09 +03:00
Pēteris Caune
eafb5d6096
Increase check limits for paid accounts. 2018-05-09 21:04:07 +03:00
Pēteris Caune
8a68ea50dc
Fix tests. Fix "View Profile" link in Subscriptions admin 2018-05-09 18:56:33 +03:00
Pēteris Caune
bc56da1d88
pruneusers command removes accounts older than 30 days that have never logged in. 2018-05-09 18:53:13 +03:00
Pēteris Caune
521b089501 Remove First & Last name from Billing Details, to limit the amount of personal data we potentially store. 2018-05-02 21:41:39 +03:00
Pēteris Caune
fbe77c9e0a Silence output from management commands when running tests. 2018-04-30 20:02:36 +03:00
Pēteris Caune
45a8bd0df1
Merge pull request #166 from MounirMesselmeni/patch-2
Use mark_safe to use html in users list admin
2018-04-30 15:50:22 +03:00
Mounir
0e4c66f395
Remove allow_tag as it's not needed anymore 2018-04-30 14:49:43 +02:00
Mounir
f9cc65c152
Use mark_safe to use html in users list admin 2018-04-30 14:23:18 +02:00
Pēteris Caune
08f6e17e91 Tests check for correctly encoded API key. 2018-04-30 13:48:54 +03:00
Pēteris Caune
e56ff22cbb
Merge pull request #165 from MounirMesselmeni/patch-1
When setting api key, decode the generated key from bytes to str to avoid b'...' bit
2018-04-30 13:46:07 +03:00
Mounir
48d12ac62c
Setting api key will save it as a byte
urlsafe_b64encode return a byte, which will result in an api key saved with something like b'apikey'
I did not tested it but urlsafe_b64decode looks like doing the job also.
2018-04-29 21:57:54 +02:00
Pēteris Caune
ef6e1870d9
Preliminary Django 2 support 2018-04-24 22:38:02 +03:00
Pēteris Caune
9bc0f1b82a
Dropping Python 2 support 2018-04-24 21:04:33 +03:00
811 changed files with 39236 additions and 11316 deletions

31
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
schedule:
- cron: '45 0 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

56
.github/workflows/django.yml vendored Normal file
View File

@ -0,0 +1,56 @@
name: Django CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-20.04
strategy:
matrix:
db: [sqlite, postgres, mysql]
python-version: [3.6, 3.7, 3.8, 3.9]
include:
- db: postgres
db_user: runner
db_password: ''
- db: mysql
db_user: root
db_password: root
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Start MySQL
if: matrix.db == 'mysql'
run: sudo systemctl start mysql.service
- name: Start PostgreSQL
if: matrix.db == 'postgres'
run: |
sudo systemctl start postgresql.service
sudo -u postgres createuser -s runner
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install apprise braintree coverage coveralls mysqlclient
- name: Run Tests
env:
DB: ${{ matrix.db }}
DB_USER: ${{ matrix.db_user }}
DB_PASSWORD: ${{ matrix.db_password }}
SECRET_KEY: dummy-key
run: |
coverage run --omit=*/tests/* --source=hc manage.py test
- name: Coveralls
if: matrix.db == 'postgres' && matrix.python-version == '3.8'
run: coveralls --service=github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -0,0 +1,35 @@
name: Publish Docker image
on:
release:
types: [published]
workflow_dispatch:
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
with:
images: healthchecks/healthchecks
- name: Build and push Docker image
uses: docker/build-push-action@v2
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
__pycache__/
*.pyc
.coverage
.env
hc.sqlite
hc/local_settings.py
static-collected

View File

@ -1,18 +0,0 @@
language: python
python:
- "2.7"
- "3.5"
- "3.6"
install:
- pip install -r requirements.txt
- pip install braintree coveralls mock mysqlclient reportlab
env:
- DB=sqlite
- DB=mysql
- DB=postgres
addons:
postgresql: "9.6"
script:
- coverage run --omit=*/tests/* --source=hc manage.py test
after_success: coveralls
cache: pip

394
CHANGELOG.md Normal file
View File

@ -0,0 +1,394 @@
# Changelog
All notable changes to this project will be documented in this file.
## v1.23.0 - Unreleased
### Bug Fixes
- Add handling for non-latin-1 characters in webhook headers
- Fix dark mode bug in selectpicker widgets
## v1.22.0 - 2020-08-06
### Improvements
- Use multicolor channel icons for better appearance in the dark mode
- Add SITE_LOGO_URL setting (#323)
- Add admin action to log in as any user
- Add a "Manager" role (#484)
- Add support for 2FA using TOTP (#354)
- Add Whitenoise (#548)
### Bug Fixes
- Fix dark mode styling issues in Cron Syntax Cheatsheet
- Fix a 403 when transferring a project to a read-only team member
- Security: fix allow_redirect function to reject absolute URLs
## v1.21.0 - 2020-07-02
### Improvements
- Increase "Success / Failure Keywords" field lengths to 200
- Django 3.2.4
- Improve the handling of unknown email addresses in the Sign In form
- Add support for "... is UP" SMS notifications
- Add an option for weekly reports (in addition to monthly)
- Implement PagerDuty Simple Install Flow, remove PD Connect
- Implement dark mode
### Bug Fixes
- Fix off-by-one-month error in monthly reports, downtime columns (#539)
## v1.20.0 - 2020-04-22
### Improvements
- Django 3.2
- Rename VictorOps -> Splunk On-Call
- Implement email body decoding in the "Ping Details" dialog
- Add a "Subject" field in the "Ping Details" dialog
- Improve HTML email display in the "Ping Details" dialog
- Add a link to check's details page in Slack notifications
- Replace details_url with cloaked_url in email and chat notifications
- In the "My Projects" page, show projects with failing checks first
### Bug Fixes
- Fix downtime summary to handle months when the check didn't exist yet (#472)
- Relax cron expression validation: accept all expressions that croniter accepts
- Fix sendalerts to clear Profile.next_nag_date if all checks up
- Fix the pause action to clear Profile.next_nag_date if all checks up
- Fix the "Email Reports" screen to clear Profile.next_nag_date if all checks up
- Fix the month boundary calculation in monthly reports (#497)
## v1.19.0 - 2021-02-03
### Improvements
- Add tighter parameter checks in hc.front.views.serve_doc
- Update OpsGenie instructions (#450)
- Update the email notification template to include more check and last ping details
- Improve the crontab snippet in the "Check Details" page (#465)
- Add Signal integration (#428)
- Change Zulip onboarding, ask for the zuliprc file (#202)
- Add a section in Docs about running self-hosted instances
- Add experimental Dockerfile and docker-compose.yml
- Add rate limiting for Pushover notifications (6 notifications / user / minute)
- Add support for disabling specific integration types (#471)
### Bug Fixes
- Fix unwanted HTML escaping in SMS and WhatsApp notifications
- Fix a crash when adding an integration for an empty Trello account
- Change icon CSS class prefix to 'ic-' to work around Fanboy's filter list
## v1.18.0 - 2020-12-09
### Improvements
- Add a tooltip to the 'confirmation link' label (#436)
- Update API to allow specifying channels by names (#440)
- When saving a phone number, remove any invisible unicode characers
- Update the read-only dashboard's CSS for better mobile support (#442)
- Reduce the number of SQL queries used in the "Get Checks" API call
- Add support for script's exit status in ping URLs (#429)
- Improve phone number sanitization: remove spaces and hyphens
- Change the "Test Integration" behavior for webhooks: don't retry failed requests
- Add retries to the the email sending logic
- Require confirmation codes (sent to email) before sensitive actions
- Implement WebAuthn two-factor authentication
- Implement badge mode (up/down vs up/late/down) selector (#282)
- Add Ping.exitstatus field, store client's reported exit status values (#455)
- Implement header-based authentication (#457)
- Add a "Lost password?" link with instructions in the Sign In page
### Bug Fixes
- Fix db field overflow when copying a check with a long name
## v1.17.0 - 2020-10-14
### Improvements
- Django 3.1
- Handle status callbacks from Twilio, show delivery failures in Integrations
- Removing unused /api/v1/notifications/{uuid}/bounce endpoint
- Less verbose output in the `senddeletionnotices` command
- Host a read-only dashboard (from github.com/healthchecks/dashboard/)
- LINE Notify integration (#412)
- Read-only team members
- API support for setting the allowed HTTP methods for making ping requests
### Bug Fixes
- Handle excessively long email addresses in the signup form
- Handle excessively long email addresses in the team member invite form
- Don't allow duplicate team memberships
- When copying a check, copy all fields from the "Filtering Rules" dialog (#417)
- Fix missing Resume button (#421)
- When decoding inbound emails, decode encoded headers (#420)
- Escape markdown in MS Teams notifications (#426)
- Set the "title" and "summary" fields in MS Teams notifications (#435)
## v1.16.0 - 2020-08-04
### Improvements
- Paused ping handling can be controlled via API (#376)
- Add "Get a list of checks's logged pings" API call (#371)
- The /api/v1/checks/ endpoint now accepts either UUID or `unique_key` (#370)
- Added /api/v1/checks/uuid/flips/ endpoint (#349)
- In the cron expression dialog, show a human-friendly version of the expression
- Indicate a started check with a progress spinner under status icon (#338)
- Added "Docs > Reliability Tips" page
- Spike.sh integration (#402)
- Updated Discord integration to use discord.com instead of discordapp.com
- Add "Failure Keyword" filtering for inbound emails (#396)
- Add support for multiple, comma-separated keywords (#396)
- New integration: phone calls (#403)
### Bug Fixes
- Removing Pager Team integration, project appears to be discontinued
- Sending a test notification updates Channel.last_error (#391)
- Handle HTTP 429 responses from Matrix server when joining a Matrix room
## v1.15.0 - 2020-06-04
### Improvements
- Rate limiting for Telegram notifications (10 notifications per chat per minute)
- Use Slack V2 OAuth flow
- Users can edit their existing webhook integrations (#176)
- Add a "Transfer Ownership" feature in Project Settings
- In checks list, the pause button asks for confirmation (#356)
- Added /api/v1/metrics/ endpoint, useful for monitoring the service itself
- Added "When paused, ignore pings" option in the Filtering Rules dialog (#369)
### Bug Fixes
- "Get a single check" API call now supports read-only API keys (#346)
- Don't escape HTML in the subject line of notification emails
- Don't let users clone checks if the account is at check limit
## v1.14.0 - 2020-03-23
### Improvements
- Improved UI to invite users from account's other projects (#258)
- Experimental Prometheus metrics endpoint (#300)
- Don't store user's current project in DB, put it explicitly in page URLs (#336)
- API reference in Markdown
- Use Selectize.js for entering tags (#324)
- Zulip integration (#202)
- OpsGenie integration returns more detailed error messages
- Telegram integration returns more detailed error messages
- Added the "Get a single check" API call (#337)
- Display project name in Slack notifications (#342)
### Bug Fixes
- The "render_docs" command checks if markdown and pygments is installed (#329)
- The team size limit is applied to the n. of distinct users across all projects (#332)
- API: don't let SuspiciousOperation bubble up when validating channel ids
- API security: check channel ownership when setting check's channels
- API: update check's "alert_after" field when changing schedule
- API: validate channel identifiers before creating/updating a check (#335)
- Fix redirect after login when adding Telegram integration
## v1.13.0 - 2020-02-13
### Improvements
- Show a red "!" in project's top navigation if any integration is not working
- createsuperuser management command requires an unique email address (#318)
- For superusers, show "Site Administration" in top navigation, note in README (#317)
- Make Ping.body size limit configurable (#301)
- Show sub-second durations with higher precision, 2 digits after decimal point (#321)
- Replace the gear icon with three horizontal dots icon (#322)
- Add a Pause button in the checks list (#312)
- Documentation in Markdown
- Added an example of capturing and submitting log output (#315)
- The sendalerts commands measures dwell time and reports it over statsd protocol
- Django 3.0.3
- Show a warning in top navigation if the project has no integrations (#327)
### Bug Fixes
- Increase the allowable length of Matrix room alias to 100 (#320)
- Make sure Check.last_ping and Ping.created timestamps match exactly
- Don't trigger "down" notifications when changing schedule interactively in web UI
- Fix sendalerts crash loop when encountering a bad cron schedule
- Stricter cron validation, reject schedules like "At midnight of February 31"
- In hc.front.views.ping_details, if a ping does not exist, return a friendly message
## v1.12.0 - 2020-01-02
### Improvements
- Django 3.0
- "Filtering Rules" dialog, an option to require HTTP POST (#297)
- Show Healthchecks version in Django admin header (#306)
- Added JSON endpoint for Shields.io (#304)
- `senddeletionnotices` command skips profiles with recent last_active_date
- The "Update Check" API call can update check's description (#311)
### Bug Fixes
- Don't set CSRF cookie on first visit. Signup is exempt from CSRF protection
- Fix List-Unsubscribe email header value: add angle brackets
- Unsubscribe links serve a form, and require HTTP POST to actually unsubscribe
- For webhook integration, validate each header line separately
- Fix "Send Test Notification" for webhooks that only fire on checks going up
- Don't allow adding webhook integrations with both URLs blank
- Don't allow adding email integrations with both "up" and "down" unchecked
## v1.11.0 - 2019-11-22
### Improvements
- In monthly reports, no downtime stats for the current month (month has just started)
- Add Microsoft Teams integration (#135)
- Add Profile.last_active_date field for more accurate inactive user detection
- Add "Shell Commands" integration (#302)
- PagerDuty integration works with or without PD_VENDOR_KEY (#303)
### Bug Fixes
- On mobile, "My Checks" page, always show the gear (Details) button (#286)
- Make log events fit better on mobile screens
## v1.10.0 - 2019-10-21
### Improvements
- Add the "Last Duration" field in the "My Checks" page (#257)
- Add "last_duration" attribute to the Check API resource (#257)
- Upgrade to psycopg2 2.8.3
- Add Go usage example
- Send monthly reports on 1st of every month, not randomly during the month
- Signup form sets the "auto-login" cookie to avoid an extra click during first login
- Autofocus the email field in the signup form, and submit on enter key
- Add support for OpsGenie EU region (#294)
- Update OpsGenie logo and setup illustrations
- Add a "Create a Copy" function for cloning checks (#288)
- Send email notification when monthly SMS sending limit is reached (#292)
### Bug Fixes
- Prevent double-clicking the submit button in signup form
- Upgrade to Django 2.2.6 fixes sqlite migrations (#284)
## v1.9.0 - 2019-09-03
### Improvements
- Show the number of downtimes and total downtime minutes in monthly reports (#104)
- Show the number of downtimes and total downtime minutes in "Check Details" page
- Add the `pruneflips` management command
- Add Mattermost integration (#276)
- Three choices in timezone switcher (UTC / check's timezone / browser's timezone) (#278)
- After adding a new check redirect to the "Check Details" page
### Bug Fixes
- Fix javascript code to construct correct URLs when running from a subdirectory (#273)
- Don't show the "Sign Up" link in the login page if registration is closed (#280)
## v1.8.0 - 2019-07-08
### Improvements
- Add the `prunetokenbucket` management command
- Show check counts in JSON "badges" (#251)
- Webhooks support HTTP PUT (#249)
- Webhooks can use different req. bodies and headers for "up" and "down" events (#249)
- Show check's code instead of full URL on 992px - 1200px wide screens (#253)
- Add WhatsApp integration (uses Twilio same as the SMS integration)
- Webhooks support the $TAGS placeholder
- Don't include ping URLs in API responses when the read-only key is used
### Bug Fixes
- Fix badges for tags containing special characters (#240, #237)
- Fix the "Integrations" page for when the user has no active project
- Prevent email clients from opening the one-time login links (#255)
- Fix `prunepings` and `prunepingsslow`, they got broken when adding Projects (#264)
## v1.7.0 - 2019-05-02
### Improvements
- Add the EMAIL_USE_VERIFICATION configuration setting (#232)
- Show "Badges" and "Settings" in top navigation (#234)
- Upgrade to Django 2.2
- Can configure the email integration to only report the "down" events (#231)
- Add "Test!" function in the Integrations page (#207)
- Rate limiting for the log in attempts
- Password strength meter and length check in the "Set Password" form
- Show the Description section even if the description is missing. (#246)
- Include the description in email alerts. (#247)
## v1.6.0 - 2019-04-01
### Improvements
- Add the "desc" field (check's description) to API responses
- Add maxlength attribute to HTML input=text elements
- Improved logic for displaying job execution times in log (#219)
- Add Matrix integration
- Add Pager Team integration
- Add a management command for sending inactive account notifications
### Bug Fixes
- Fix refreshing of the checks page filtered by tags (#221)
- Escape asterisks in Slack messages (#223)
- Fix a "invalid time format" in front.views.status_single on Windows hosts
## v1.5.0 - 2019-02-04
### Improvements
- Database schema: add uniqueness constraint to Check.code
- Database schema: add Ping.kind field. Remove "start" and "fail" fields
- Add "Email Settings..." dialog and "Subject Must Contain" setting
- Database schema: add the Project model
- Move project-specific settings to a new "Project Settings" page
- Add a "Transfer to Another Project..." dialog
- Add the "My Projects" page
## v1.4.0 - 2018-12-25
### Improvements
- Set Pushover alert priorities for "down" and "up" events separately
- Additional python usage examples
- Allow simultaneous access to checks from different teams
- Add CORS support to API endpoints
- Flip model, for tracking status changes of the Check objects
- Add `/ping/<code>/start` API endpoint
- When using the `/start` endpoint, show elapsed times in ping log
### Bug Fixes
- Fix after-login redirects (the "?next=" query parameter)
- Update Check.status field when user edits timeout & grace settings
- Use timezone-aware datetimes with croniter, avoid ambiguities around DST
- Validate and reject cron schedules with six components
## v1.3.0 - 2018-11-21
### Improvements
- Load settings from environment variables
- Add "List-Unsubscribe" header to alert and report emails
- Don't send monthly reports to inactive accounts (no pings in 6 months)
- Add search box in the "My Checks" page
- Add read-only API key support
- Remove Profile.bill_to field (obsolete)
- Show a warning when running with DEBUG=True
- Add "channels" attribute to the Check API resource
- Can specify channel codes when updating a check via API
- Add a workaround for email agents automatically opening "Unsubscribe" links
- Add Channel.name field, users can now name integrations
- Add "Get a List of Existing Integrations" API call
### Bug Fixes
- During DST transition, handle ambiguous dates as pre-transition
## v1.2.0 - 2018-10-20
### Improvements
- Content updates in the "Welcome" page.
- Added "Docs > Third-Party Resources" page.
- Improved layout and styling in "Login" page.
- Separate "Sign Up" and "Log In" forms.
- "My Checks" page: support filtering checks by query string parameters.
- Added Trello integration
### Bug Fixes
- Timezones were missing in the "Change Schedule" dialog, fixed.
- Fix hamburger menu button in "Login" page.
## v1.1.0 - 2018-08-20
### Improvements
- A new "Check Details" page.
- Updated django-compressor, psycopg2, pytz, requests package versions.
- C# usage example.
- Checks have a "Description" field.

23
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,23 @@
# Contributing
I'm open to feature suggestions and happy to review code contributions.
If you are planning to contribute something larger than a small, straightforward
bugfix, please open an issue so we can discuss it first. Otherwise you are risking a
"no" or a "yes, but let's do it differently" to an already implemented feature.
## Code Style
* Format your Python code with [black](https://black.readthedocs.io/en/stable/).
* Prefer simplicity over cleverness.
* If you are fixing a bug or adding a feature, add a test. Run tests before
submitting pull requests.
## Adding Documentation
This project uses the Markdown format for documentation. Use the `render_docs`
management command to generate the HTML version of the documentation. To add a new
documentation page:
1. Create the appropriate .md file under `templates/docs`
2. Generate the HTML version with `./manage.py render_docs`
3. Add the page to the navigation in `/templates/front/base_docs.html`

View File

@ -1,4 +1,4 @@
Copyright (c) 2015, Pēteris Caune
Copyright (c) 2015, Pēteris Caune and other contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

403
README.md
View File

@ -1,6 +1,6 @@
# healthchecks
# Healthchecks
[![Build Status](https://travis-ci.org/healthchecks/healthchecks.svg?branch=master)](https://travis-ci.org/healthchecks/healthchecks)
![Build Status](https://github.com/healthchecks/healthchecks/workflows/Django%20CI/badge.svg)
[![Coverage Status](https://coveralls.io/repos/healthchecks/healthchecks/badge.svg?branch=master&service=github)](https://coveralls.io/github/healthchecks/healthchecks?branch=master)
@ -14,161 +14,133 @@
![Screenshot of Integrations page](/static/img/channels.png?raw=true "Integrations Page")
healthchecks is a watchdog for your cron jobs. It's a web server that listens for pings from your cron jobs, plus a web interface.
Healthchecks is a cron job monitoring service. It listens for HTTP requests
and email messages ("pings") from your cron jobs and scheduled tasks ("checks").
When a ping does not arrive on time, Healthchecks sends out alerts.
It is live here: [http://healthchecks.io/](http://healthchecks.io/)
Healthchecks comes with a web dashboard, API, 25+ integrations for
delivering notifications, monthly email reports, WebAuthn 2FA support,
team management features: projects, team members, read-only access.
The building blocks are:
* Python 2 or Python 3
* Django 1.11
* Python 3.6+
* Django 3
* PostgreSQL or MySQL
Healthchecks is licensed under the BSD 3-clause license.
Healthchecks is available as a hosted service
at [https://healthchecks.io/](https://healthchecks.io/).
## Setting Up for Development
These are instructions for setting up healthchecks Django app
in development environment.
To set up Healthchecks development environment:
* prepare directory for project code and virtualenv:
* Install dependencies (Debian/Ubuntu):
$ sudo apt-get update
$ sudo apt-get install -y gcc python3-dev python3-venv libpq-dev
* Prepare directory for project code and virtualenv. Feel free to use a
different location:
$ mkdir -p ~/webapps
$ cd ~/webapps
* prepare virtual environment
* Prepare virtual environment
(with virtualenv you get pip, we'll use it soon to install requirements):
$ virtualenv --python=python3 hc-venv
$ python3 -m venv hc-venv
$ source hc-venv/bin/activate
$ pip3 install wheel # make sure wheel is installed in the venv
* check out project code:
* Check out project code:
$ git clone https://github.com/healthchecks/healthchecks.git
* install requirements (Django, ...) into virtualenv:
* Install requirements (Django, ...) into virtualenv:
$ pip install -r healthchecks/requirements.txt
* healthchecks is configured to use a SQLite database by default. To use
PostgreSQL or MySQL database, create and edit `hc/local_settings.py` file.
There is a template you can copy and edit as needed:
$ cd ~/webapps/healthchecks
$ cp hc/local_settings.py.example hc/local_settings.py
* create database tables and the superuser account:
* Create database tables and a superuser account:
$ cd ~/webapps/healthchecks
$ ./manage.py migrate
$ ./manage.py createsuperuser
* run development server:
With the default configuration, Healthchecks stores data in a SQLite file
`hc.sqlite` in the checkout directory (`~/webapps/healthchecks`).
To use PostgreSQL or MySQL, see the section **Database Configuration** section
below.
* Run tests:
$ ./manage.py test
* Run development server:
$ ./manage.py runserver
The site should now be running at `http://localhost:8080`
To log into Django administration site as a super user,
visit `http://localhost:8080/admin`
The site should now be running at `http://localhost:8000`.
To access Django administration site, log in as a superuser, then
visit `http://localhost:8000/admin/`
## Configuration
Site configuration is kept in `hc/settings.py`. Additional configuration
is loaded from `hc/local_settings.py` file, if it exists. You
can create this file (should be right next to `settings.py` in the filesystem)
and override settings as needed.
Healthchecks reads configuration from environment variables.
Some useful settings keys to override are:
[Full list of configuration parameters](https://healthchecks.io/docs/self_hosted_configuration/).
`SITE_ROOT` is used to build fully qualified URLs for pings, and for use in
emails and notifications. Example:
## Accessing Administration Panel
```python
SITE_ROOT = "https://my-monitoring-project.com"
```
Healthchecks comes with Django's administration panel where you can manually
view and modify user accounts, projects, checks, integrations etc. To access it,
`SITE_NAME` has the default value of "Mychecks" and is used throughout
the templates. Replace it with your own name to personalize your installation.
Example:
* if you haven't already, create a superuser account: `./manage.py createsuperuser`
* log into the site using superuser credentials
* in the top navigation, "Account" dropdown, select "Site Administration"
```python
SITE_NAME = "My Monitoring Project"
```
`REGISTRATION_OPEN` controls whether site visitors can create new accounts.
Set it to `False` if you are setting up a private healthchecks instance, but
it needs to be publicly accessible (so, for example, your cloud services
can send pings).
If you close new user registration, you can still selectively invite users
to your team account.
## Database Configuration
Database configuration is stored in `hc/settings.py` and can be overriden
in `hc/local_settings.py`. The default database engine is SQLite. To use
PostgreSQL, create `hc/local_settings.py` if it does not exist, and put the
following in it, changing it as neccessary:
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'your-database-name-here',
'USER': 'your-database-user-here',
'PASSWORD': 'your-database-password-here',
'TEST': {'CHARSET': 'UTF8'}
}
}
```
For MySQL:
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'your-database-name-here',
'USER': 'your-database-user-here',
'PASSWORD': 'your-database-password-here',
'TEST': {'CHARSET': 'UTF8'}
}
}
```
You can also use `hc/local_settings.py` to read database
configuration from environment variables like so:
```python
import os
DATABASES = {
'default': {
'ENGINE': os.environ['DB_ENGINE'],
'NAME': os.environ['DB_NAME'],
'USER': os.environ['DB_USER'],
'PASSWORD': os.environ['DB_PASSWORD'],
'TEST': {'CHARSET': 'UTF8'}
}
}
```
## Sending Emails
healthchecks must be able to send email messages, so it can send out login
links and alerts to users. Put your SMTP server configuration in
`hc/local_settings.py` like so:
Healthchecks must be able to send email messages, so it can send out login
links and alerts to users. Specify your SMTP credentials using the following
environment variables:
```python
EMAIL_HOST = "your-smtp-server-here.com"
EMAIL_PORT = 587
EMAIL_HOST_USER = "username"
EMAIL_HOST_PASSWORD = "password"
EMAIL_HOST_USER = "smtp-username"
EMAIL_HOST_PASSWORD = "smtp-password"
EMAIL_USE_TLS = True
```
For more information, have a look at Django documentation,
[Sending Email](https://docs.djangoproject.com/en/1.10/topics/email/) section.
## Receiving Emails
Healthchecks comes with a `smtpd` management command, which starts up a
SMTP listener service. With the command running, you can ping your
checks by sending email messages
to `your-uuid-here@my-monitoring-project.com` email addresses.
Start the SMTP listener on port 2525:
$ ./manage.py smtpd --port 2525
Send a test email:
$ curl --url 'smtp://127.0.0.1:2525' \
--mail-from 'foo@example.org' \
--mail-rcpt '11111111-1111-1111-1111-111111111111@my-monitoring-project.com' \
-F '='
## Sending Status Notifications
healtchecks comes with a `sendalerts` management command, which continuously
@ -183,7 +155,7 @@ manager like [supervisor](http://supervisord.org/) or systemd.
## Database Cleanup
With time and use the healthchecks database will grow in size. You may
With time and use the Healthchecks database will grow in size. You may
decide to prune old data: inactive user accounts, old checks not assigned
to users, records of outgoing email messages and records of received pings.
There are separate Django management commands for each task:
@ -195,13 +167,10 @@ There are separate Django management commands for each task:
$ ./manage.py prunepings
```
* Remove checks older than 2 hours that are not assigned to users. Such
checks are by-products of random visitors and robots loading the welcome
page and never setting up an account:
```
$ ./manage.py prunechecks
```
Note: 100 is the default value but you can configure a different
limit per-user. To do that, go to the
Administration Panel, look up user's **Profile** and modify its
"Ping log limit" field.
* Remove old records of sent notifications. For each check, remove
notifications that are older than the oldest stored ping for same check.
@ -211,9 +180,9 @@ There are separate Django management commands for each task:
```
* Remove user accounts that match either of these conditions:
* Account was created more than 6 months ago, and user has never logged in.
* Account was created more than 6 months ago, and user has never logged in.
These can happen when user enters invalid email address when signing up.
* Last login was more than 6 months ago, and the account has no checks.
* Last login was more than 6 months ago, and the account has no checks.
Assume the user doesn't intend to use the account any more and would
probably *want* it removed.
@ -221,13 +190,80 @@ There are separate Django management commands for each task:
$ ./manage.py pruneusers
```
* 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.
```
$ ./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
backups set up.
## Two-factor Authentication
Healthchecks optionally supports two-factor authentication using the WebAuthn
standard. To enable WebAuthn support, set the `RP_ID` (relying party identifier )
setting to a non-null value. Set its value to your site's domain without scheme
and without port. For example, if your site runs on `https://my-hc.example.org`,
set `RP_ID` to `my-hc.example.org`.
Note that WebAuthn requires HTTPS, even if running on localhost. To test WebAuthn
locally with a self-signed certificate, you can use the `runsslserver` command
from the `django-sslserver` package.
## External Authentication
Healthchecks supports external authentication by means of HTTP headers set by
reverse proxies or the WSGI server. This allows you to integrate it into your
existing authentication system (e.g., LDAP or OAuth) via an authenticating proxy.
When this option is enabled, **healtchecks will trust the header's value implicitly**,
so it is **very important** to ensure that attackers cannot set the value themselves
(and thus impersonate any user). How to do this varies by your chosen proxy,
but generally involves configuring it to strip out headers that normalize to the
same name as the chosen identity header.
To enable this feature, set the `REMOTE_USER_HEADER` value to a header you wish to
authenticate with. HTTP headers will be prefixed with `HTTP_` and have any dashes
converted to underscores. Headers without that prefix can be set by the WSGI server
itself only, which is more secure.
When `REMOTE_USER_HEADER` is set, Healthchecks will:
- assume the header contains user's email address
- look up and automatically log in the user with a matching email address
- automatically create an user account if it does not exist
- disable the default authentication methods (login link to email, password)
## Integrations
### Slack
To enable the Slack "self-service" integration, you will need to create a "Slack App".
To do so:
* Create a _new Slack app_ on https://api.slack.com/apps/
* Add at least _one scope_ in the permissions section to be able to deploy the app in your workspace (By example `incoming-webhook` for the `Bot Token Scopes`
https://api.slack.com/apps/APP_ID/oauth?).
* Add a _redirect url_ in the format `SITE_ROOT/integrations/add_slack_btn/`.
For example, if your SITE_ROOT is `https://my-hc.example.org` then the redirect URL would be
`https://my-hc.example.org/integrations/add_slack_btn/`.
* Look up your Slack app for the Client ID and Client Secret at https://api.slack.com/apps/APP_ID/general? . Put them
in `SLACK_CLIENT_ID` and `SLACK_CLIENT_SECRET` environment
variables.
### Discord
To enable Discord integration, you will need to:
@ -237,29 +273,55 @@ To enable Discord integration, you will need to:
`SITE_ROOT/integrations/add_discord/`. For example, if you are running a
development server on `localhost:8000` then the redirect URI would be
`http://localhost:8000/integrations/add_discord/`
* Look up your Discord app's Client ID and Client Secret. Add them
to your `hc/local_settings.py` file as `DISCORD_CLIENT_ID` and
`DISCORD_CLIENT_SECRET` fields.
* Look up your Discord app's Client ID and Client Secret. Put them
in `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET` environment
variables.
### 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
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. The Pushover subscription URL should look similar to
`https://pushover.net/subscribe/yourAppName-randomAlphaNumericData`.
### Signal
Healthchecks uses [signal-cli](https://github.com/AsamK/signal-cli) to send Signal
notifications. Healthcecks interacts with signal-cli over DBus.
To enable the Signal integration:
* Set up and configure signal-cli to listen on DBus system bus ([instructions](https://github.com/AsamK/signal-cli/wiki/DBus-service)).
Make sure you can send test messages from command line, using the `dbus-send`
example given in the signal-cli instructions.
* Set the `SIGNAL_CLI_ENABLED` environment variable to `True`.
* register a new application on https://pushover.net/apps/build
* enable subscriptions in your application and make sure to enable the URL
subscription type
* add the application token and subscription URL to `hc/local_settings.py`, as
`PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL`
### Telegram
* Create a Telegram bot by talking to the
[BotFather](https://core.telegram.org/bots#6-botfather). Set the bot's name,
description, user picture, and add a "/start" command.
* After creating the bot you will have the bot's name and token. Add them
to your `hc/local_settings.py` file as `TELEGRAM_BOT_NAME` and
`TELEGRAM_TOKEN` fields.
* After creating the bot you will have the bot's name and token. Put them
in `TELEGRAM_BOT_NAME` and `TELEGRAM_TOKEN` environment variables.
* Run `settelegramwebhook` management command. This command tells Telegram
where to forward channel messages by invoking Telegram's
[setWebhook](https://core.telegram.org/bots/api#setwebhook) API call:
@ -271,3 +333,92 @@ 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.
### Shell Commands
The "Shell Commands" integration runs user-defined local shell commands when checks
go up or down. This integration is disabled by default, and can be enabled by setting
the `SHELL_ENABLED` environment variable to `True`.
Note: be careful when using "Shell Commands" integration, and only enable it when
you fully trust the users of your Healthchecks instance. The commands will be executed
by the `manage.py sendalerts` process, and will run with the same system permissions as
the `sendalerts` process.
### Matrix
To enable the Matrix integration you will need to:
* Register a bot user (for posting notifications) in your preferred homeserver.
* Use the [Login API call](https://www.matrix.org/docs/guides/client-server-api#login)
to retrieve bot user's access token. You can run it as shown in the documentation,
using curl in command shell.
* Set the `MATRIX_` environment variables. Example:
```
MATRIX_HOMESERVER=https://matrix.org
MATRIX_USER_ID=@mychecks:matrix.org
MATRIX_ACCESS_TOKEN=[a long string of characters returned by the login call]
```
### PagerDuty Simple Install Flow
To enable PagerDuty [Simple Install Flow](https://developer.pagerduty.com/docs/app-integration-development/events-integration/),
* Register a PagerDuty app at [PagerDuty](https://pagerduty.com/) Developer Mode My Apps
* In the newly created app, add the "Events Integration" functionality
* Specify a Redirect URL: `https://your-domain.com/integrations/add_pagerduty/`
* Copy the displayed app_id value (PXXXXX) and put it in the `PD_APP_ID` 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. Consider setting up exception logging with [Sentry](https://sentry.io/for/django/).
* Management commands that need to be run during each deployment.
* `manage.py compress` creates combined JS and CSS bundles and
places them in the `static-collected` directory.
* `manage.py collectstatic` collects static files in the `static-collected`
directory.
* `manage.py migrate` applies any pending database schema changes
and data migrations.
* 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/).
* `manage.py sendalerts` is the process that monitors checks and sends out
monitoring alerts. It must be always running, it must be started on reboot, and it
must be restarted if it itself crashes. On modern linux systems, a good option is
to [define a systemd service](https://github.com/healthchecks/healthchecks/issues/273#issuecomment-520560304)
for it.
* Static files. Healthchecks serves static files on its own, no configuration
required. It uses the [Whitenoise library](http://whitenoise.evans.io/en/stable/index.html)
for this.
* 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).

67
docker/.env Normal file
View File

@ -0,0 +1,67 @@
ALLOWED_HOSTS=localhost
APPRISE_ENABLED=False
DB=postgres
DB_CONN_MAX_AGE=0
DB_HOST=db
DB_NAME=hc
DB_PASSWORD=fixme-postgres-password
DB_PORT=5432
DB_SSLMODE=prefer
DB_TARGET_SESSION_ATTRS=read-write
DB_USER=postgres
DEBUG=False
DEFAULT_FROM_EMAIL=healthchecks@example.org
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
EMAIL_HOST=
EMAIL_HOST_PASSWORD=
EMAIL_HOST_USER=
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_USE_VERIFICATION=True
LINENOTIFY_CLIENT_ID=
LINENOTIFY_CLIENT_SECRET=
MASTER_BADGE_LABEL=Mychecks
MATRIX_ACCESS_TOKEN=
MATRIX_HOMESERVER=
MATRIX_USER_ID=
MATTERMOST_ENABLED=True
MSTEAMS_ENABLED=True
OPSGENIE_ENABLED=True
PAGERTREE_ENABLED=True
PD_APP_ID=
PD_ENABLED=True
PD_VENDOR_KEY=
PING_BODY_LIMIT=10000
PING_EMAIL_DOMAIN=localhost
PING_ENDPOINT=http://localhost:8000/ping/
PROMETHEUS_ENABLED=True
PUSHBULLET_CLIENT_ID=
PUSHBULLET_CLIENT_SECRET=
PUSHOVER_API_TOKEN=
PUSHOVER_EMERGENCY_EXPIRATION=86400
PUSHOVER_EMERGENCY_RETRY_DELAY=300
PUSHOVER_SUBSCRIPTION_URL=
REGISTRATION_OPEN=True
REMOTE_USER_HEADER=
RP_ID=
SECRET_KEY=---
SHELL_ENABLED=False
SIGNAL_CLI_ENABLED=False
SITE_NAME=Mychecks
SITE_ROOT=http://localhost:8000
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_ENABLED=True
SPIKE_ENABLED=True
TELEGRAM_BOT_NAME=ExampleBot
TELEGRAM_TOKEN=
TRELLO_APP_KEY=
TWILIO_ACCOUNT=
TWILIO_AUTH=
TWILIO_FROM=
TWILIO_USE_WHATSAPP=False
USE_PAYMENTS=False
VICTOROPS_ENABLED=True
WEBHOOKS_ENABLED=True
ZULIP_ENABLED=True

23
docker/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM python:3.9
RUN useradd --system hc
ENV PYTHONUNBUFFERED=1
ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1
WORKDIR /opt/healthchecks
COPY requirements.txt /tmp
RUN \
pip install --no-cache-dir -r /tmp/requirements.txt && \
pip install uwsgi
COPY . /opt/healthchecks/
RUN \
rm -f /opt/healthchecks/hc/local_settings.py && \
DEBUG=False SECRET_KEY=build-key ./manage.py collectstatic --noinput && \
DEBUG=False SECRET_KEY=build-key ./manage.py compress
USER hc
CMD [ "uwsgi", "/opt/healthchecks/docker/uwsgi.ini"]

32
docker/README.md Normal file
View File

@ -0,0 +1,32 @@
# Running with Docker
This is a sample configuration for running Healthchecks with
[Docker](https://www.docker.com) and [Docker Compose](https://docs.docker.com/compose/).
**Note: The Docker configuration is a recent addition, and, for the time being,
should be considered as highly experimental**.
Note: For the sake of simplicity, the sample configuration starts a single database
node and a single web server node, both on the same host. It also does not handle SSL
termination. If you plan to expose it to the public internet, make sure you put a
SSL-terminating load balancer or reverse proxy in front of it.
## Getting Started
* Add your configuration in the `/docker/.env` file.
As a minimum, set the following fields:
* `DEFAULT_FROM_EMAIL` the "From:" address for outbound emails
* `EMAIL_HOST` the SMTP server
* `EMAIL_HOST_PASSWORD` the SMTP password
* `EMAIL_HOST_USER` the SMTP username
* `SECRET_KEY` secures HTTP sessions, set to a random value
* Create and start containers:
$ docker-compose up
* Create a superuser:
$ docker-compose run web /opt/healthchecks/manage.py createsuperuser
* Open [http://localhost:8000](http://localhost:8000) in your browser and log in with
the credentials from the previous step.

24
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,24 @@
version: "3"
volumes:
db-data:
services:
db:
image: postgres:12
volumes:
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=$DB_NAME
- POSTGRES_PASSWORD=$DB_PASSWORD
web:
build:
context: ..
dockerfile: docker/Dockerfile
env_file:
- .env
ports:
- 8000:8000
depends_on:
- db
command: bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; uwsgi /opt/healthchecks/docker/uwsgi.ini'

17
docker/uwsgi.ini Normal file
View File

@ -0,0 +1,17 @@
[uwsgi]
master
die-on-term
http-socket = :8000
harakiri = 10
post-buffering = 4096
processes = 4
enable-threads
threads = 1
chdir = /opt/healthchecks
module = hc.wsgi:application
thunder-lock
disable-write-exception
hook-pre-app = exec:./manage.py migrate
attach-daemon = ./manage.py sendalerts
attach-daemon = ./manage.py sendreports --loop

View File

@ -1,12 +1,35 @@
from django.contrib import admin
from django.contrib.auth import login as auth_login
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from django.db.models import Count
from django.db.models import Count, F
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from hc.accounts.models import Profile
from hc.api.models import Channel, Check
from hc.accounts.models import Credential, Profile, Project
@mark_safe
def _format_usage(num_checks, num_channels):
result = ""
if num_checks == 0:
result += "0 checks, "
elif num_checks == 1:
result += "1 check, "
else:
result += f"<strong>{num_checks} checks</strong>, "
if num_channels == 0:
result += "0 channels"
elif num_channels == 1:
result += "1 channel"
else:
result += f"<strong>{num_channels} channels</strong>"
return result
class Fieldset:
@ -20,110 +43,200 @@ class Fieldset:
class ProfileFieldset(Fieldset):
name = "User Profile"
fields = ("email", "api_key", "current_team", "reports_allowed",
"next_report_date", "nag_period", "next_nag_date",
"token", "sort")
fields = (
"email",
"reports",
"tz",
"theme",
"next_report_date",
"nag_period",
"next_nag_date",
"deletion_notice_date",
"token",
"sort",
)
class TeamFieldset(Fieldset):
name = "Team"
fields = ("team_name", "team_limit", "check_limit",
"ping_log_limit", "sms_limit", "sms_sent", "last_sms_date",
"bill_to")
fields = (
"team_limit",
"check_limit",
"ping_log_limit",
"sms_limit",
"sms_sent",
"last_sms_date",
"call_limit",
"calls_sent",
"last_call_date",
)
class NumChecksFilter(admin.SimpleListFilter):
title = "check count"
parameter_name = "num_checks"
def lookups(self, request, model_admin):
return (
("10", "more than 10"),
("20", "more than 20"),
("50", "more than 50"),
("100", "more than 100"),
("500", "more than 500"),
("1000", "more than 1000"),
)
def queryset(self, request, queryset):
if not self.value():
return
value = int(self.value())
return queryset.filter(num_checks__gt=value)
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
class Media:
css = {
'all': ('css/admin/profiles.css',)
}
css = {"all": ("css/admin/profiles.css",)}
readonly_fields = ("user", "email")
raw_id_fields = ("current_team", )
list_select_related = ("user", )
list_display = ("id", "users", "checks", "invited",
"reports_allowed", "ping_log_limit", "sms")
search_fields = ["id", "user__email"]
list_filter = ("team_limit", "reports_allowed",
"check_limit", "next_report_date")
list_per_page = 30
list_select_related = ("user",)
list_display = (
"id",
"email",
"checks",
"date_joined",
"last_active_date",
"projects",
"invited",
"sms",
"reports",
)
list_filter = (
"user__date_joined",
"last_active_date",
"reports",
"check_limit",
NumChecksFilter,
"theme",
)
actions = ("login",)
fieldsets = (ProfileFieldset.tuple(), TeamFieldset.tuple())
def get_queryset(self, request):
qs = super(ProfileAdmin, self).get_queryset(request)
qs = qs.annotate(Count("member", distinct=True))
qs = qs.annotate(Count("user__check", distinct=True))
qs = qs.prefetch_related("user__project_set")
qs = qs.annotate(num_members=Count("user__project__member", distinct=True))
qs = qs.annotate(num_checks=Count("user__project__check", distinct=True))
qs = qs.annotate(plan=F("user__subscription__plan_name"))
return qs
@mark_safe
def users(self, obj):
if obj.member__count == 0:
return obj.user.email
else:
return render_to_string("admin/profile_list_team.html", {
"profile": obj
})
def email(self, obj):
s = escape(obj.user.email)
if obj.plan:
s = "%s <span>%s</span>" % (s, obj.plan)
return s
def date_joined(self, obj):
return obj.user.date_joined
@mark_safe
def projects(self, obj):
return render_to_string("admin/profile_list_projects.html", {"profile": obj})
@mark_safe
def checks(self, obj):
num_checks = obj.user__check__count
pct = 100 * num_checks / max(obj.check_limit, 1)
pct = min(100, int(pct))
return """
<span class="bar"><span style="width: %dpx"></span></span>
&nbsp; %d of %d
""" % (pct, num_checks, obj.check_limit)
s = "%d of %d" % (obj.num_checks, obj.check_limit)
if obj.num_checks > 1:
s = "<b>%s</b>" % s
return s
def invited(self, obj):
return "%d of %d" % (obj.member__count, obj.team_limit)
return "%d of %d" % (obj.num_members, obj.team_limit)
def sms(self, obj):
return "%d of %d" % (obj.sms_sent, obj.sms_limit)
def login(self, request, qs):
profile = qs.get()
auth_login(request, profile.user, "hc.accounts.backends.EmailBackend")
return redirect("hc-index")
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
readonly_fields = ("code", "owner")
list_select_related = ("owner",)
list_display = ("id", "name_", "users", "usage", "switch")
search_fields = ["id", "name", "owner__email"]
class Media:
css = {"all": ("css/admin/projects.css",)}
def get_queryset(self, request):
qs = super(ProjectAdmin, self).get_queryset(request)
qs = qs.annotate(num_channels=Count("channel", distinct=True))
qs = qs.annotate(num_checks=Count("check", distinct=True))
qs = qs.annotate(num_members=Count("member", distinct=True))
return qs
def name_(self, obj):
if obj.name:
return obj.name
return "Default Project for %s" % obj.owner.email
@mark_safe
def users(self, obj):
if obj.num_members == 0:
return obj.owner.email
else:
return render_to_string("admin/project_list_team.html", {"project": obj})
def email(self, obj):
return obj.user.email
return obj.owner.email
def usage(self, obj):
return _format_usage(obj.num_checks, obj.num_channels)
@mark_safe
def switch(self, obj):
url = reverse("hc-checks", args=[obj.code])
return "<a href='%s'>Show Checks</a>" % url
class HcUserAdmin(UserAdmin):
actions = ["send_report"]
list_display = ('id', 'email', 'date_joined', 'engagement',
'is_staff', 'checks')
actions = ["send_report", "send_nag"]
list_display = (
"id",
"email",
"usage",
"date_joined",
"last_login",
"is_staff",
)
list_display_links = ("id", "email")
list_filter = ("last_login", "date_joined", "is_staff", "is_active")
ordering = ["-id"]
def engagement(self, user):
result = ""
num_checks = Check.objects.filter(user=user).count()
num_channels = Channel.objects.filter(user=user).count()
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(num_checks=Count("project__check", distinct=True))
qs = qs.annotate(num_channels=Count("project__channel", distinct=True))
if num_checks == 0:
result += "0 checks, "
elif num_checks == 1:
result += "1 check, "
else:
result += "<strong>%d checks</strong>, " % num_checks
return qs
if num_channels == 0:
result += "0 channels"
elif num_channels == 1:
result += "1 channel, "
else:
result += "<strong>%d channels</strong>, " % num_channels
return result
engagement.allow_tags = True
def checks(self, user):
url = reverse("hc-switch-team", args=[user.username])
return "<a href='%s'>Checks</a>" % url
checks.allow_tags = True
@mark_safe
def usage(self, user):
return _format_usage(user.num_checks, user.num_channels)
def send_report(self, request, qs):
for user in qs:
@ -131,6 +244,22 @@ class HcUserAdmin(UserAdmin):
self.message_user(request, "%d email(s) sent" % qs.count())
def send_nag(self, request, qs):
for user in qs:
user.profile.send_report(nag=True)
self.message_user(request, "%d email(s) sent" % qs.count())
admin.site.unregister(User)
admin.site.register(User, HcUserAdmin)
@admin.register(Credential)
class CredentialAdmin(admin.ModelAdmin):
list_display = ("id", "created", "email", "name")
search_fields = ["id", "code", "name", "user__email"]
list_filter = ["created"]
def email(self, obj):
return obj.user.email

View File

@ -1,19 +1,21 @@
from django.contrib.auth.models import User
from django.conf import settings
from hc.accounts.models import Profile
from hc.accounts.views import _make_user
class BasicBackend(object):
def get_user(self, user_id):
try:
return User.objects.select_related("profile").get(pk=user_id)
q = User.objects.select_related("profile")
return q.get(pk=user_id)
except User.DoesNotExist:
return None
# Authenticate against the token in user's profile.
class ProfileBackend(BasicBackend):
def authenticate(self, request=None, username=None, token=None):
try:
profiles = Profile.objects.select_related("user")
@ -28,7 +30,6 @@ class ProfileBackend(BasicBackend):
class EmailBackend(BasicBackend):
def authenticate(self, request=None, username=None, password=None):
try:
user = User.objects.get(email=username)
@ -37,3 +38,31 @@ class EmailBackend(BasicBackend):
if user.check_password(password):
return user
class CustomHeaderBackend(BasicBackend):
"""
This backend works in conjunction with the ``CustomHeaderMiddleware``,
and is used when the server is handling authentication outside of Django.
"""
def authenticate(self, request, remote_user_email):
"""
The email address passed as remote_user_email is considered trusted.
Return the User object with the given email address. Create a new User
if it does not exist.
"""
# This backend should only be used when header-based authentication is enabled
assert settings.REMOTE_USER_HEADER
# remote_user_email should have a value
assert remote_user_email
try:
user = User.objects.get(email=remote_user_email)
except User.DoesNotExist:
user = _make_user(remote_user_email)
return user

51
hc/accounts/decorators.py Normal file
View File

@ -0,0 +1,51 @@
from functools import wraps
import secrets
from django.core.signing import TimestampSigner, SignatureExpired
from django.shortcuts import redirect, render
from hc.api.models import TokenBucket
from hc.lib import emails
def _session_unsign(request, key, max_age):
if key not in request.session:
return None
try:
return TimestampSigner().unsign(request.session[key], max_age=max_age)
except SignatureExpired:
pass
def require_sudo_mode(f):
@wraps(f)
def wrapper(request, *args, **kwds):
assert request.user.is_authenticated
# is sudo mode active and has not expired yet?
if _session_unsign(request, "sudo", 1800) == "active":
return f(request, *args, **kwds)
if not TokenBucket.authorize_sudo_code(request.user):
return render(request, "try_later.html")
# has the user submitted a code to enter sudo mode?
if "sudo_code" in request.POST:
ours = _session_unsign(request, "sudo_code", 900)
if ours and ours == request.POST["sudo_code"]:
request.session.pop("sudo_code")
request.session["sudo"] = TimestampSigner().sign("active")
return redirect(request.path)
if not _session_unsign(request, "sudo_code", 900):
code = "%06d" % secrets.randbelow(1000000)
request.session["sudo_code"] = TimestampSigner().sign(code)
emails.sudo_code(request.user.email, {"sudo_code": code})
ctx = {}
if "sudo_code" in request.POST:
ctx["wrong_code"] = True
return render(request, "accounts/sudo.html", ctx)
return wrapper

View File

@ -1,23 +1,104 @@
import base64
import binascii
from datetime import timedelta as td
from django import forms
from django.core.exceptions import ValidationError
from django.contrib.auth import authenticate
from django.contrib.auth.models import User
from hc.accounts.models import REPORT_CHOICES, Member
from hc.api.models import TokenBucket
import pytz
class LowercaseEmailField(forms.EmailField):
def clean(self, value):
value = super(LowercaseEmailField, self).clean(value)
return value.lower()
class EmailPasswordForm(forms.Form):
class Base64Field(forms.CharField):
def to_python(self, value):
if value is None:
return None
try:
return base64.b64decode(value.encode())
except binascii.Error:
raise ValidationError(message="Cannot decode base64")
class SignupForm(forms.Form):
# Call it "identity" instead of "email"
# to avoid some of the dumber bots
identity = LowercaseEmailField(
error_messages={"required": "Please enter your email address."}
)
tz = forms.CharField(required=False)
def clean_identity(self):
v = self.cleaned_data["identity"]
if len(v) > 254:
raise forms.ValidationError("Address is too long.")
if User.objects.filter(email=v).exists():
raise forms.ValidationError(
"An account with this email address already exists."
)
return v
def clean_tz(self):
# Declare tz as "clean" only if we can find it in pytz.all_timezones
if self.cleaned_data["tz"] in pytz.all_timezones:
return self.cleaned_data["tz"]
# Otherwise, return None, and *don't* throw a validation exception:
# If user's browser reports a timezone we don't recognize, we
# should ignore the timezone but still save the rest of the form.
class EmailLoginForm(forms.Form):
# Call it "identity" instead of "email"
# to avoid some of the dumber bots
identity = LowercaseEmailField()
def clean_identity(self):
v = self.cleaned_data["identity"]
if not TokenBucket.authorize_login_email(v):
raise forms.ValidationError("Too many attempts, please try later.")
try:
self.user = User.objects.get(email=v)
except User.DoesNotExist:
raise forms.ValidationError("Unknown email address.")
return v
class PasswordLoginForm(forms.Form):
email = LowercaseEmailField()
password = forms.CharField(required=False)
password = forms.CharField()
def clean(self):
username = self.cleaned_data.get("email")
password = self.cleaned_data.get("password")
if username and password:
if not TokenBucket.authorize_login_password(username):
raise forms.ValidationError("Too many attempts, please try later.")
self.user = authenticate(username=username, password=password)
if self.user is None or not self.user.is_active:
raise forms.ValidationError("Incorrect email or password.")
return self.cleaned_data
class ReportSettingsForm(forms.Form):
reports_allowed = forms.BooleanField(required=False)
reports = forms.ChoiceField(choices=REPORT_CHOICES)
nag_period = forms.IntegerField(min_value=0, max_value=86400)
tz = forms.CharField()
def clean_nag_period(self):
seconds = self.cleaned_data["nag_period"]
@ -27,9 +108,18 @@ class ReportSettingsForm(forms.Form):
return td(seconds=seconds)
def clean_tz(self):
# Declare tz as "clean" only if we can find it in pytz.all_timezones
if self.cleaned_data["tz"] in pytz.all_timezones:
return self.cleaned_data["tz"]
# Otherwise, return None, and *don't* throw a validation exception:
# If user's browser reports a timezone we don't recognize, we
# should ignore the timezone but still save the rest of the form.
class SetPasswordForm(forms.Form):
password = forms.CharField()
password = forms.CharField(min_length=8)
class ChangeEmailForm(forms.Form):
@ -39,18 +129,51 @@ class ChangeEmailForm(forms.Form):
def clean_email(self):
v = self.cleaned_data["email"]
if User.objects.filter(email=v).exists():
raise forms.ValidationError("%s is not available" % v)
raise forms.ValidationError("%s is already registered" % v)
return v
class InviteTeamMemberForm(forms.Form):
email = LowercaseEmailField()
email = LowercaseEmailField(max_length=254)
role = forms.ChoiceField(choices=Member.Role.choices)
class RemoveTeamMemberForm(forms.Form):
email = LowercaseEmailField()
class TeamNameForm(forms.Form):
team_name = forms.CharField(max_length=200, required=True)
class ProjectNameForm(forms.Form):
name = forms.CharField(max_length=60)
class TransferForm(forms.Form):
email = LowercaseEmailField()
class AddWebAuthnForm(forms.Form):
name = forms.CharField(max_length=100)
client_data_json = Base64Field()
attestation_object = Base64Field()
class WebAuthnForm(forms.Form):
credential_id = Base64Field()
client_data_json = Base64Field()
authenticator_data = Base64Field()
signature = Base64Field()
class TotpForm(forms.Form):
error_css_class = "has-error"
code = forms.RegexField(regex=r"^\d{6}$")
def __init__(self, totp, post=None, files=None):
self.totp = totp
super(TotpForm, self).__init__(post, files)
def clean_code(self):
if not self.totp.verify(self.cleaned_data["code"], valid_window=1):
raise forms.ValidationError("The code you entered was incorrect.")
return self.cleaned_data["code"]

View File

@ -0,0 +1,42 @@
import getpass
from django.core.management.base import BaseCommand
from hc.accounts.forms import SignupForm
from hc.accounts.views import _make_user
class Command(BaseCommand):
help = """Create a super-user account."""
def handle(self, *args, **options):
email = None
password = None
while not email:
raw = input("Email address:")
form = SignupForm({"identity": raw})
if not form.is_valid():
self.stderr.write("Error: " + " ".join(form.errors["identity"]))
continue
email = form.cleaned_data["identity"]
while not password:
p1 = getpass.getpass()
p2 = getpass.getpass("Password (again):")
if p1.strip() == "":
self.stderr.write("Error: Blank passwords aren't allowed.")
continue
if p1 != p2:
self.stderr.write("Error: Your passwords didn't match.")
continue
password = p1
user = _make_user(email)
user.set_password(password)
user.is_staff = True
user.is_superuser = True
user.save()
return "Superuser created successfully."

View File

@ -2,40 +2,42 @@ from datetime import timedelta
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
from django.db.models import Count
from django.utils import timezone
from django.db.models import Count, F
from django.utils.timezone import now
from hc.accounts.models import Profile
class Command(BaseCommand):
help = """Prune old, inactive user accounts.
Conditions for removing an user account:
- created 6 months ago and never logged in. Does not belong
- created 1 month ago and never logged in. Does not belong
to any team.
Use case: visitor types in their email at the website but
never follows through with login.
- not logged in for 6 months, and has no checks. Does not
belong to any team.
Use case: user wants to remove their account. So they
remove all checks and leave the account at that.
"""
def handle(self, *args, **options):
cutoff = timezone.now() - timedelta(days=180)
month_ago = now() - timedelta(days=30)
# Old accounts, never logged in, no team memberships
q = User.objects
q = User.objects.order_by("id")
q = q.annotate(n_teams=Count("memberships"))
q = q.filter(date_joined__lt=cutoff, last_login=None, n_teams=0)
n1, _ = q.delete()
q = q.filter(date_joined__lt=month_ago, last_login=None, n_teams=0)
# Not logged in for 1 month, 0 checks, no team memberships
q = User.objects
q = q.annotate(n_checks=Count("check"))
q = q.annotate(n_teams=Count("memberships"))
q = q.filter(last_login__lt=cutoff, n_checks=0, n_teams=0)
n2, _ = q.delete()
n, summary = q.delete()
count = summary.get("auth.User", 0)
self.stdout.write("Pruned %d never-logged-in user accounts." % count)
return "Done! Pruned %d user accounts." % (n1 + n2)
# Profiles scheduled for deletion
q = Profile.objects.order_by("id")
q = q.filter(deletion_notice_date__lt=month_ago)
# Exclude users who have logged in after receiving deletion notice
q = q.exclude(user__last_login__gt=F("deletion_notice_date"))
for profile in q:
self.stdout.write("Deleting inactive %s" % profile.user.email)
profile.user.delete()
return "Done!"

View File

@ -0,0 +1,77 @@
from datetime import timedelta
import time
from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from hc.accounts.models import Profile, Member
from hc.api.models import Ping
from hc.lib import emails
class Command(BaseCommand):
help = """Send deletion notices to inactive user accounts.
Conditions for sending the notice:
- deletion notice has not been sent recently
- last login more than a year ago
- none of the owned projects has invited team members
- none of the owned projects has pings in the last year
- is on a free plan
"""
def pause(self):
time.sleep(1)
def handle(self, *args, **options):
year_ago = now() - timedelta(days=365)
q = Profile.objects.order_by("id")
# Exclude accounts with logins in the last year
q = q.exclude(user__last_login__gt=year_ago)
# Exclude accounts less than a year old
q = q.exclude(user__date_joined__gt=year_ago)
# Exclude accounts with the deletion notice already sent
q = q.exclude(deletion_notice_date__gt=year_ago)
# Exclude accounts with activity in the last year
q = q.exclude(last_active_date__gt=year_ago)
# Exclude paid accounts
q = q.exclude(sms_limit__gt=5)
sent = 0
skipped_has_team = 0
skipped_has_pings = 0
for profile in q:
members = Member.objects.filter(project__owner_id=profile.user_id)
if members.exists():
# Don't send deletion notice: this account has team members
skipped_has_team += 1
continue
pings = Ping.objects.filter(owner__project__owner_id=profile.user_id)
pings = pings.filter(created__gt=year_ago)
if pings.exists():
# Don't send deletion notice: this account has pings in the last year
skipped_has_pings += 1
continue
self.stdout.write("Sending notice to %s" % profile.user.email)
profile.deletion_notice_date = now()
profile.save()
ctx = {"email": profile.user.email, "support_email": settings.SUPPORT_EMAIL}
emails.deletion_notice(profile.user.email, ctx)
sent += 1
# Throttle so we don't send too many emails at once:
self.pause()
return (
f"Done!\n"
f"* Notices sent: {sent}\n"
f"* Skipped (has team members): {skipped_has_team}\n"
f"* Skipped (has pings in the last year): {skipped_has_pings}\n"
)

View File

@ -1,3 +1,7 @@
from django.contrib import auth
from django.contrib.auth.middleware import RemoteUserMiddleware
from django.conf import settings
from hc.accounts.models import Profile
@ -9,11 +13,51 @@ class TeamAccessMiddleware(object):
if not request.user.is_authenticated:
return self.get_response(request)
teams_q = Profile.objects.filter(member__user_id=request.user.id)
teams_q = teams_q.select_related("user")
request.get_teams = lambda: list(teams_q)
request.profile = Profile.objects.for_user(request.user)
request.team = request.profile.team()
return self.get_response(request)
class CustomHeaderMiddleware(RemoteUserMiddleware):
"""
Middleware for utilizing Web-server-provided authentication.
If request.user is not authenticated, then this middleware:
- looks for an email address in request.META[settings.REMOTE_USER_HEADER]
- looks up and automatically logs in the user with a matching email
"""
def process_request(self, request):
if not settings.REMOTE_USER_HEADER:
return
# Make sure AuthenticationMiddleware is installed
assert hasattr(request, "user")
email = request.META.get(settings.REMOTE_USER_HEADER)
if not email:
# If specified header doesn't exist or is empty then log out any
# authenticated user and return
if request.user.is_authenticated:
auth.logout(request)
return
# If the user is already authenticated and that user is the user we are
# getting passed in the headers, then the correct user is already
# persisted in the session and we don't need to continue.
if request.user.is_authenticated:
if request.user.email == email:
return
else:
# An authenticated user is associated with the request, but
# it does not match the authorized user in the header.
auth.logout(request)
# We are seeing this user for the first time in this session, attempt
# to authenticate the user.
user = auth.authenticate(request, remote_user_email=email)
if user:
# User is valid. Set request.user and persist user in the session
# by logging the user in.
request.user = user
auth.login(request, user)

View File

@ -7,18 +7,32 @@ from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name='Profile',
name="Profile",
fields=[
('id', models.AutoField(auto_created=True, serialize=False, verbose_name='ID', primary_key=True)),
('next_report_date', models.DateTimeField(null=True, blank=True)),
('reports_allowed', models.BooleanField(default=True)),
('user', models.OneToOneField(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
(
"id",
models.AutoField(
auto_created=True,
serialize=False,
verbose_name="ID",
primary_key=True,
),
),
("next_report_date", models.DateTimeField(null=True, blank=True)),
("reports_allowed", models.BooleanField(default=True)),
(
"user",
models.OneToOneField(
blank=True,
to=settings.AUTH_USER_MODEL,
null=True,
on_delete=models.CASCADE,
),
),
],
),
)
]

View File

@ -6,14 +6,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
dependencies = [("accounts", "0001_initial")]
operations = [
migrations.AddField(
model_name='profile',
name='ping_log_limit',
model_name="profile",
name="ping_log_limit",
field=models.IntegerField(default=100),
),
)
]

View File

@ -7,14 +7,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_profile_ping_log_limit'),
]
dependencies = [("accounts", "0002_profile_ping_log_limit")]
operations = [
migrations.AddField(
model_name='profile',
name='token',
model_name="profile",
name="token",
field=models.CharField(blank=True, max_length=128),
),
)
]

View File

@ -7,14 +7,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_profile_token'),
]
dependencies = [("accounts", "0003_profile_token")]
operations = [
migrations.AddField(
model_name='profile',
name='api_key',
model_name="profile",
name="api_key",
field=models.CharField(blank=True, max_length=128),
),
)
]

View File

@ -11,34 +11,46 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('accounts', '0004_profile_api_key'),
("accounts", "0004_profile_api_key"),
]
operations = [
migrations.CreateModel(
name='Member',
name="Member",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
)
],
),
migrations.AddField(
model_name='profile',
name='team_access_allowed',
model_name="profile",
name="team_access_allowed",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='team_name',
model_name="profile",
name="team_name",
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='member',
name='team',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Profile'),
model_name="member",
name="team",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="accounts.Profile"
),
),
migrations.AddField(
model_name='member',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
model_name="member",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
]

View File

@ -8,14 +8,16 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_auto_20160509_0801'),
]
dependencies = [("accounts", "0005_auto_20160509_0801")]
operations = [
migrations.AddField(
model_name='profile',
name='current_team',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.Profile'),
),
model_name="profile",
name="current_team",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="accounts.Profile",
),
)
]

View File

@ -7,14 +7,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_profile_current_team'),
]
dependencies = [("accounts", "0006_profile_current_team")]
operations = [
migrations.AddField(
model_name='profile',
name='check_limit',
model_name="profile",
name="check_limit",
field=models.IntegerField(default=20),
),
)
]

View File

@ -7,14 +7,10 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0007_profile_check_limit'),
]
dependencies = [("accounts", "0007_profile_check_limit")]
operations = [
migrations.AddField(
model_name='profile',
name='bill_to',
field=models.TextField(blank=True),
),
model_name="profile", name="bill_to", field=models.TextField(blank=True)
)
]

View File

@ -7,24 +7,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_profile_bill_to'),
]
dependencies = [("accounts", "0008_profile_bill_to")]
operations = [
migrations.AddField(
model_name='profile',
name='last_sms_date',
model_name="profile",
name="last_sms_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='sms_limit',
field=models.IntegerField(default=0),
model_name="profile", name="sms_limit", field=models.IntegerField(default=0)
),
migrations.AddField(
model_name='profile',
name='sms_sent',
field=models.IntegerField(default=0),
model_name="profile", name="sms_sent", field=models.IntegerField(default=0)
),
]

View File

@ -7,14 +7,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0009_auto_20170714_1734'),
]
dependencies = [("accounts", "0009_auto_20170714_1734")]
operations = [
migrations.AddField(
model_name='profile',
name='team_limit',
model_name="profile",
name="team_limit",
field=models.IntegerField(default=2),
),
)
]

View File

@ -7,14 +7,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_profile_team_limit'),
]
dependencies = [("accounts", "0010_profile_team_limit")]
operations = [
migrations.AddField(
model_name='profile',
name='sort',
field=models.CharField(default='created', max_length=20),
),
model_name="profile",
name="sort",
field=models.CharField(default="created", max_length=20),
)
]

View File

@ -8,19 +8,24 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0011_profile_sort'),
]
dependencies = [("accounts", "0011_profile_sort")]
operations = [
migrations.AddField(
model_name='profile',
name='nag_period',
field=models.DurationField(choices=[(datetime.timedelta(0), 'Disabled'), (datetime.timedelta(0, 3600), 'Hourly'), (datetime.timedelta(1), 'Daily')], default=datetime.timedelta(0)),
model_name="profile",
name="nag_period",
field=models.DurationField(
choices=[
(datetime.timedelta(0), "Disabled"),
(datetime.timedelta(0, 3600), "Hourly"),
(datetime.timedelta(1), "Daily"),
],
default=datetime.timedelta(0),
),
),
migrations.AddField(
model_name='profile',
name='next_nag_date',
model_name="profile",
name="next_nag_date",
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -7,13 +7,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0012_auto_20171014_1002'),
]
dependencies = [("accounts", "0012_auto_20171014_1002")]
operations = [
migrations.RemoveField(
model_name='profile',
name='team_access_allowed',
),
migrations.RemoveField(model_name="profile", name="team_access_allowed")
]

View File

@ -9,14 +9,16 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('accounts', '0013_remove_profile_team_access_allowed'),
]
dependencies = [("accounts", "0013_remove_profile_team_access_allowed")]
operations = [
migrations.AlterField(
model_name='member',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to=settings.AUTH_USER_MODEL),
),
model_name="member",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="memberships",
to=settings.AUTH_USER_MODEL,
),
)
]

View File

@ -0,0 +1,21 @@
# Generated by Django 2.1.2 on 2018-10-29 18:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0014_auto_20171227_1530")]
operations = [
migrations.AddField(
model_name="profile",
name="api_key_id",
field=models.CharField(blank=True, max_length=128),
),
migrations.AddField(
model_name="profile",
name="api_key_readonly",
field=models.CharField(blank=True, max_length=128),
),
]

View File

@ -0,0 +1,10 @@
# Generated by Django 2.1.2 on 2018-11-06 08:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("accounts", "0015_auto_20181029_1858")]
operations = [migrations.RemoveField(model_name="profile", name="bill_to")]

View File

@ -0,0 +1,63 @@
# Generated by Django 2.1.5 on 2019-01-12 14:26
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("accounts", "0016_remove_profile_bill_to"),
]
operations = [
migrations.CreateModel(
name="Project",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"code",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("name", models.CharField(blank=True, max_length=200)),
("api_key", models.CharField(blank=True, max_length=128)),
("api_key_readonly", models.CharField(blank=True, max_length=128)),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddField(
model_name="member",
name="project",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="accounts.Project",
),
),
migrations.AddField(
model_name="profile",
name="current_project",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="accounts.Project",
),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 2.1.5 on 2019-01-11 14:49
from django.db import migrations
def create_projects(apps, schema_editor):
Profile = apps.get_model("accounts", "Profile")
Project = apps.get_model("accounts", "Project")
Member = apps.get_model("accounts", "Member")
for profile in Profile.objects.all():
project = Project()
project.name = profile.team_name
project.owner_id = profile.user_id
project.api_key = profile.api_key
project.api_key_readonly = profile.api_key_readonly
project.save()
profile.current_project = project
profile.save()
Member.objects.filter(team=profile).update(project=project)
for profile in Profile.objects.all():
if profile.current_team_id:
profile.current_project = profile.current_team.current_project
profile.save()
class Migration(migrations.Migration):
dependencies = [("accounts", "0017_auto_20190112_1426")]
operations = [migrations.RunPython(create_projects, migrations.RunPython.noop)]

View File

@ -0,0 +1,16 @@
# Generated by Django 2.1.5 on 2019-01-12 19:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0018_auto_20190112_1426")]
operations = [
migrations.AddField(
model_name="project",
name="badge_key",
field=models.CharField(blank=True, max_length=150, null=True),
)
]

View File

@ -0,0 +1,17 @@
# Generated by Django 2.1.5 on 2019-01-12 19:50
from django.db import migrations
def set_badge_key(apps, schema_editor):
Project = apps.get_model("accounts", "Project")
for project in Project.objects.select_related("owner").all():
project.badge_key = project.owner.username
project.save()
class Migration(migrations.Migration):
dependencies = [("accounts", "0019_project_badge_key")]
operations = [migrations.RunPython(set_badge_key, migrations.RunPython.noop)]

View File

@ -0,0 +1,16 @@
# Generated by Django 2.1.5 on 2019-01-12 20:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0020_auto_20190112_1950")]
operations = [
migrations.AlterField(
model_name="project",
name="badge_key",
field=models.CharField(max_length=150, unique=True),
)
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.1.5 on 2019-01-14 08:57
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("accounts", "0021_auto_20190112_2005")]
operations = [
migrations.AlterField(
model_name="member",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="accounts.Project"
),
)
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.1.5 on 2019-01-17 14:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("accounts", "0022_auto_20190114_0857")]
operations = [
migrations.RemoveField(model_name="profile", name="api_key"),
migrations.RemoveField(model_name="profile", name="api_key_id"),
migrations.RemoveField(model_name="profile", name="api_key_readonly"),
]

View File

@ -0,0 +1,13 @@
# Generated by Django 2.1.5 on 2019-01-19 15:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("accounts", "0023_auto_20190117_1419")]
operations = [
migrations.RemoveField(model_name="profile", name="current_team"),
migrations.RemoveField(model_name="profile", name="team_name"),
]

View File

@ -0,0 +1,10 @@
# Generated by Django 2.1.5 on 2019-01-22 08:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("accounts", "0024_auto_20190119_1540")]
operations = [migrations.RemoveField(model_name="member", name="team")]

View File

@ -0,0 +1,27 @@
# Generated by Django 2.1.5 on 2019-02-04 20:42
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [("accounts", "0025_remove_member_team")]
operations = [
migrations.AlterField(
model_name="project",
name="api_key",
field=models.CharField(blank=True, db_index=True, max_length=128),
),
migrations.AlterField(
model_name="project",
name="api_key_readonly",
field=models.CharField(blank=True, db_index=True, max_length=128),
),
migrations.AlterField(
model_name="project",
name="code",
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 2.1.7 on 2019-03-12 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0026_auto_20190204_2042")]
operations = [
migrations.AddField(
model_name="profile",
name="deletion_notice_date",
field=models.DateTimeField(blank=True, null=True),
)
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.6 on 2019-11-19 13:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0027_profile_deletion_notice_date'),
]
operations = [
migrations.AddField(
model_name='profile',
name='last_active_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='profile',
name='sms_limit',
field=models.IntegerField(default=5),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.0.1 on 2020-03-02 07:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0028_auto_20191119_1346'),
]
operations = [
migrations.RemoveField(
model_name='profile',
name='current_project',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.4 on 2020-04-11 13:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0029_remove_profile_current_project'),
]
operations = [
migrations.AddField(
model_name='member',
name='transfer_request_date',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.0.8 on 2020-08-03 14:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0030_member_transfer_request_date'),
]
operations = [
migrations.AddField(
model_name='profile',
name='call_limit',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='profile',
name='calls_sent',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='profile',
name='last_call_date',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.1 on 2020-08-19 07:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0031_auto_20200803_1413'),
]
operations = [
migrations.AddConstraint(
model_name='member',
constraint=models.UniqueConstraint(fields=('user', 'project'), name='accounts_member_no_duplicates'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1 on 2020-08-24 16:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0032_auto_20200819_0757'),
]
operations = [
migrations.AddField(
model_name='member',
name='rw',
field=models.BooleanField(default=True),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.1.2 on 2020-11-14 09:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('accounts', '0033_member_rw'),
]
operations = [
migrations.CreateModel(
name='Credential',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.UUIDField(default=uuid.uuid4, unique=True)),
('name', models.CharField(max_length=100)),
('created', models.DateTimeField(auto_now_add=True)),
('data', models.BinaryField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credentials', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.2 on 2021-05-24 07:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0034_credential'),
]
operations = [
migrations.AddField(
model_name='profile',
name='reports',
field=models.CharField(choices=[('off', 'Off'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='monthly', max_length=10),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.2 on 2021-05-24 07:38
from django.db import migrations
def fill_reports_field(apps, schema_editor):
Profile = apps.get_model("accounts", "Profile")
Profile.objects.filter(reports_allowed=False).update(reports="off")
Profile.objects.filter(reports_allowed=True).update(reports="monthly")
class Migration(migrations.Migration):
dependencies = [
("accounts", "0035_profile_reports"),
]
operations = [migrations.RunPython(fill_reports_field, migrations.RunPython.noop)]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.2 on 2021-05-24 09:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0036_fill_profile_reports'),
]
operations = [
migrations.AddField(
model_name='profile',
name='tz',
field=models.CharField(default='UTC', max_length=36),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.4 on 2021-06-18 09:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0037_profile_tz'),
]
operations = [
migrations.AddField(
model_name='profile',
name='theme',
field=models.CharField(blank=True, max_length=10, null=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.4 on 2021-06-29 11:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0038_profile_theme'),
]
operations = [
migrations.RemoveField(
model_name='profile',
name='reports_allowed',
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.4 on 2021-07-22 12:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0039_remove_profile_reports_allowed'),
]
operations = [
migrations.AddField(
model_name='member',
name='role',
field=models.CharField(choices=[('r', 'Read-only'), ('w', 'Member')], default='w', max_length=1),
),
migrations.AlterField(
model_name='member',
name='rw',
field=models.BooleanField(default=True, null=True),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.4 on 2021-07-22 13:25
from django.db import migrations
def fill_member_role(apps, schema_editor):
Member = apps.get_model("accounts", "Member")
Member.objects.filter(rw=False).update(role="r")
Member.objects.filter(rw=True).update(role="w")
class Migration(migrations.Migration):
dependencies = [
("accounts", "0040_auto_20210722_1244"),
]
operations = [
migrations.RunPython(fill_member_role, migrations.RunPython.noop),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.4 on 2021-07-22 14:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0041_fill_role'),
]
operations = [
migrations.RemoveField(
model_name='member',
name='rw',
),
]

View File

@ -0,0 +1,17 @@
from django.db import migrations, models
from hc.accounts.models import Member
class Migration(migrations.Migration):
dependencies = [
('accounts', '0042_remove_member_rw'),
]
operations = [
migrations.AlterField(
model_name='member',
name='role',
field=models.CharField(choices=Member.Role.choices, default=Member.Role.REGULAR, max_length=1),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.4 on 2021-07-30 09:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0043_add_role_manager'),
]
operations = [
migrations.AddField(
model_name='profile',
name='totp',
field=models.CharField(blank=True, max_length=32, null=True),
),
migrations.AddField(
model_name='profile',
name='totp_created',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -1,21 +1,30 @@
from base64 import urlsafe_b64encode
import os
from datetime import timedelta
import random
from secrets import token_urlsafe
from urllib.parse import quote, urlencode
import uuid
from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password
from django.contrib.auth.models import User
from django.core.signing import TimestampSigner
from django.db import models
from django.db.models import Count, Q
from django.urls import reverse
from django.utils import timezone
from fido2.ctap2 import AttestedCredentialData
from hc.lib import emails
from hc.lib.date import month_boundaries
import pytz
NO_NAG = timedelta()
NAG_PERIODS = ((NO_NAG, "Disabled"),
(timedelta(hours=1), "Hourly"),
(timedelta(days=1), "Daily"))
NAG_PERIODS = (
(NO_NAG, "Disabled"),
(timedelta(hours=1), "Hourly"),
(timedelta(days=1), "Daily"),
)
REPORT_CHOICES = (("off", "Off"), ("weekly", "Weekly"), ("monthly", "Monthly"))
def month(dt):
@ -33,6 +42,7 @@ class ProfileManager(models.Manager):
# If not using payments, set high limits
profile.check_limit = 500
profile.sms_limit = 500
profile.call_limit = 500
profile.team_limit = 500
profile.save()
@ -40,29 +50,37 @@ class ProfileManager(models.Manager):
class Profile(models.Model):
# Owner:
user = models.OneToOneField(User, models.CASCADE, blank=True, null=True)
team_name = models.CharField(max_length=200, blank=True)
next_report_date = models.DateTimeField(null=True, blank=True)
reports_allowed = models.BooleanField(default=True)
reports = models.CharField(max_length=10, default="monthly", choices=REPORT_CHOICES)
nag_period = models.DurationField(default=NO_NAG, choices=NAG_PERIODS)
next_nag_date = models.DateTimeField(null=True, blank=True)
ping_log_limit = models.IntegerField(default=100)
check_limit = models.IntegerField(default=20)
token = models.CharField(max_length=128, blank=True)
api_key = models.CharField(max_length=128, blank=True)
current_team = models.ForeignKey("self", models.SET_NULL, null=True)
bill_to = models.TextField(blank=True)
last_sms_date = models.DateTimeField(null=True, blank=True)
sms_limit = models.IntegerField(default=0)
sms_limit = models.IntegerField(default=5)
sms_sent = models.IntegerField(default=0)
last_call_date = models.DateTimeField(null=True, blank=True)
call_limit = models.IntegerField(default=0)
calls_sent = models.IntegerField(default=0)
team_limit = models.IntegerField(default=2)
sort = models.CharField(max_length=20, default="created")
deletion_notice_date = models.DateTimeField(null=True, blank=True)
last_active_date = models.DateTimeField(null=True, blank=True)
tz = models.CharField(max_length=36, default="UTC")
theme = models.CharField(max_length=10, null=True, blank=True)
totp = models.CharField(max_length=32, null=True, blank=True)
totp_created = models.DateTimeField(null=True, blank=True)
objects = ProfileManager()
def __str__(self):
return self.team_name or self.user.email
return "Profile for %s" % self.user.email
def notifications_url(self):
return settings.SITE_ROOT + reverse("hc-notifications")
@ -73,15 +91,8 @@ class Profile(models.Model):
path = reverse("hc-unsubscribe-reports", args=[signed_username])
return settings.SITE_ROOT + path
def team(self):
# compare ids to avoid SQL queries
if self.current_team_id and self.current_team_id != self.id:
return self.current_team
return self
def prepare_token(self, salt):
token = urlsafe_b64encode(os.urandom(24)).decode("utf-8")
token = token_urlsafe(24)
self.token = make_password(token, salt)
self.save()
return token
@ -89,52 +100,86 @@ class Profile(models.Model):
def check_token(self, token, salt):
return salt in self.token and check_password(token, self.token)
def send_instant_login_link(self, inviting_profile=None):
def send_instant_login_link(self, inviting_project=None, redirect_url=None):
token = self.prepare_token("login")
path = reverse("hc-check-token", args=[self.user.username, token])
if redirect_url:
path += "?next=%s" % redirect_url
ctx = {
"button_text": "Log In",
"button_text": "Sign In",
"button_url": settings.SITE_ROOT + path,
"inviting_profile": inviting_profile
"inviting_project": inviting_project,
}
emails.login(self.user.email, ctx)
def send_set_password_link(self):
token = self.prepare_token("set-password")
path = reverse("hc-set-password", args=[token])
def send_transfer_request(self, project):
token = self.prepare_token("login")
settings_path = reverse("hc-project-settings", args=[project.code])
path = reverse("hc-check-token", args=[self.user.username, token])
path += "?next=%s" % settings_path
ctx = {
"button_text": "Set Password",
"button_url": settings.SITE_ROOT + path
"button_text": "Project Settings",
"button_url": settings.SITE_ROOT + path,
"project": project,
}
emails.set_password(self.user.email, ctx)
emails.transfer_request(self.user.email, ctx)
def send_change_email_link(self):
token = self.prepare_token("change-email")
path = reverse("hc-change-email", args=[token])
ctx = {
"button_text": "Change Email",
"button_url": settings.SITE_ROOT + path
}
emails.change_email(self.user.email, ctx)
def send_sms_limit_notice(self, transport):
ctx = {"transport": transport, "limit": self.sms_limit}
if self.sms_limit != 500 and settings.USE_PAYMENTS:
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
def set_api_key(self):
self.api_key = urlsafe_b64encode(os.urandom(24))
self.save()
emails.sms_limit(self.user.email, ctx)
def checks_from_all_teams(self):
""" Return a queryset of checks from all teams we have access for. """
def send_call_limit_notice(self):
ctx = {"limit": self.call_limit}
if self.call_limit != 500 and settings.USE_PAYMENTS:
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
team_ids = set(self.user.memberships.values_list("team_id", flat=True))
team_ids.add(self.id)
emails.call_limit(self.user.email, ctx)
def projects(self):
""" Return a queryset of all projects we have access to. """
is_owner = Q(owner_id=self.user_id)
is_member = Q(member__user_id=self.user_id)
q = Project.objects.filter(is_owner | is_member)
return q.distinct().order_by("name")
def annotated_projects(self):
""" Return all projects, annotated with 'n_down'. """
# Subquery for getting project ids
project_ids = self.projects().values("id")
# Main query with the n_down annotation.
# Must use the subquery, otherwise ORM gets confused by
# joins and group by's
q = Project.objects.filter(id__in=project_ids)
n_down = Count("check", filter=Q(check__status="down"))
q = q.annotate(n_down=n_down)
return q.order_by("name")
def checks_from_all_projects(self):
""" Return a queryset of checks from projects we have access to. """
project_ids = self.projects().values("id")
from hc.api.models import Check
return Check.objects.filter(user__profile__id__in=team_ids)
return Check.objects.filter(project_id__in=project_ids)
def send_report(self, nag=False):
checks = self.checks_from_all_teams()
checks = self.checks_from_all_projects()
# Is there at least one check that has received a ping?
if not checks.filter(last_ping__isnull=False).exists():
# Has there been a ping in last 6 months?
result = checks.aggregate(models.Max("last_ping"))
last_ping = result["last_ping__max"]
six_months_ago = timezone.now() - timedelta(days=180)
if last_ping is None or last_ping < six_months_ago:
return False
# Is there at least one check that is down?
@ -142,42 +187,42 @@ class Profile(models.Model):
if nag and num_down == 0:
return False
# Sort checks by owner. Need this because will group by owner in
# Sort checks by project. Need this because will group by project in
# template.
checks = checks.select_related("user", "user__profile")
checks = checks.order_by("user_id")
checks = checks.select_related("project")
checks = checks.order_by("project_id")
# list() executes the query, to avoid DB access while
# rendering the template
checks = list(checks)
unsub_url = self.reports_unsub_url()
headers = {
"List-Unsubscribe": "<%s>" % unsub_url,
"X-Bounce-Url": unsub_url,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
boundaries = month_boundaries(months=3)
# throw away the current month, keep two previous months
boundaries.pop()
ctx = {
"checks": checks,
"sort": self.sort,
"now": timezone.now(),
"unsub_link": self.reports_unsub_url(),
"unsub_link": unsub_url,
"notifications_url": self.notifications_url(),
"nag": nag,
"nag_period": self.nag_period.total_seconds(),
"num_down": num_down
"num_down": num_down,
"month_boundaries": boundaries,
"monthly_or_weekly": self.reports,
}
emails.report(self.user.email, ctx)
emails.report(self.user.email, ctx, headers)
return True
def can_invite(self):
return self.member_set.count() < self.team_limit
def invite(self, user):
member = Member(team=self, user=user)
member.save()
# Switch the invited user over to the new team so they
# notice the new team on next visit:
user.profile.current_team = self
user.profile.save()
user.profile.send_instant_login_link(self)
def sms_sent_this_month(self):
# IF last_sms_date was never set, we have not sent any messages yet.
if not self.last_sms_date:
@ -201,19 +246,211 @@ class Profile(models.Model):
self.save()
return True
def set_next_nag_date(self):
""" Set next_nag_date for all members of this team. """
def calls_sent_this_month(self):
# IF last_call_date was never set, we have not made any phone calls yet.
if not self.last_call_date:
return 0
is_owner = models.Q(id=self.id)
is_member = models.Q(user__memberships__team=self)
q = Profile.objects.filter(is_owner | is_member)
q = q.exclude(nag_period=NO_NAG)
# Exclude profiles with next_nag_date already set
q = q.filter(next_nag_date__isnull=True)
# If last sent date is not from this month, we've made 0 calls this month.
if month(timezone.now()) > month(self.last_call_date):
return 0
q.update(next_nag_date=timezone.now() + models.F("nag_period"))
return self.calls_sent
def authorize_call(self):
""" If monthly limit not exceeded, increase counter and return True """
sent_this_month = self.calls_sent_this_month()
if sent_this_month >= self.call_limit:
return False
self.calls_sent = sent_this_month + 1
self.last_call_date = timezone.now()
self.save()
return True
def num_checks_used(self):
from hc.api.models import Check
return Check.objects.filter(project__owner_id=self.user_id).count()
def num_checks_available(self):
return self.check_limit - self.num_checks_used()
def can_accept(self, project):
return project.num_checks() <= self.num_checks_available()
def update_next_nag_date(self):
any_down = self.checks_from_all_projects().filter(status="down").exists()
if any_down and self.next_nag_date is None and self.nag_period:
self.next_nag_date = timezone.now() + self.nag_period
self.save(update_fields=["next_nag_date"])
elif not any_down and self.next_nag_date:
self.next_nag_date = None
self.save(update_fields=["next_nag_date"])
def choose_next_report_date(self):
""" Calculate the target date for the next monthly/weekly report.
Monthly reports should get sent on 1st of each month, between
9AM and 11AM in user's timezone.
Weekly reports should get sent on Mondays, between
9AM and 11AM in user's timezone.
"""
if self.reports == "off":
return None
tz = pytz.timezone(self.tz)
dt = timezone.now().astimezone(tz)
dt = dt.replace(hour=9, minute=0) + timedelta(minutes=random.randrange(0, 120))
while True:
dt += timedelta(days=1)
if self.reports == "monthly" and dt.day == 1:
return dt
elif self.reports == "weekly" and dt.weekday() == 0:
return dt
class Project(models.Model):
code = models.UUIDField(default=uuid.uuid4, unique=True)
name = models.CharField(max_length=200, blank=True)
owner = models.ForeignKey(User, models.CASCADE)
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)
def __str__(self):
return self.name or self.owner.email
@property
def owner_profile(self):
return Profile.objects.for_user(self.owner)
def num_checks(self):
return self.check_set.count()
def num_checks_available(self):
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)
self.save()
def invite_suggestions(self):
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
q = q.exclude(memberships__project=self)
return q.distinct().order_by("email")
def can_invite_new_users(self):
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
used = q.distinct().count()
return used < self.owner_profile.team_limit
def invite(self, user, role):
if Member.objects.filter(user=user, project=self).exists():
return False
if self.owner_id == user.id:
return False
Member.objects.create(user=user, project=self, role=role)
checks_url = reverse("hc-checks", args=[self.code])
user.profile.send_instant_login_link(self, redirect_url=checks_url)
return True
def update_next_nag_dates(self):
""" Update next_nag_date on profiles of all members of this project. """
is_owner = Q(user_id=self.owner_id)
is_member = Q(user__memberships__project=self)
q = Profile.objects.filter(is_owner | is_member).exclude(nag_period=NO_NAG)
for profile in q:
profile.update_next_nag_date()
def overall_status(self):
if not hasattr(self, "_overall_status"):
self._overall_status = "up"
for check in self.check_set.all():
check_status = check.get_status()
if check_status == "grace" and self._overall_status == "up":
self._overall_status = "grace"
elif check_status == "down":
self._overall_status = "down"
break
return self._overall_status
def get_n_down(self):
result = 0
for check in self.check_set.all():
if check.get_status() == "down":
result += 1
return result
def have_channel_issues(self):
errors = list(self.channel_set.values_list("last_error", flat=True))
# It's a problem if a project has no integrations at all
if len(errors) == 0:
return True
# It's a problem if any integration has a logged error
return True if max(errors) else False
def transfer_request(self):
return self.member_set.filter(transfer_request_date__isnull=False).first()
def dashboard_url(self):
if not self.api_key_readonly:
return None
frag = urlencode({self.api_key_readonly: str(self)}, quote_via=quote)
return reverse("hc-dashboard") + "#" + frag
def checks_url(self):
return settings.SITE_ROOT + reverse("hc-checks", args=[self.code])
class Member(models.Model):
team = models.ForeignKey(Profile, models.CASCADE)
class Role(models.TextChoices):
READONLY = "r", "Read-only"
REGULAR = "w", "Member"
MANAGER = "m", "Manager"
user = models.ForeignKey(User, models.CASCADE, related_name="memberships")
project = models.ForeignKey(Project, models.CASCADE)
transfer_request_date = models.DateTimeField(null=True, blank=True)
role = models.CharField(max_length=1, default=Role.REGULAR, choices=Role.choices)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "project"], name="accounts_member_no_duplicates"
)
]
def can_accept(self):
return self.user.profile.can_accept(self.project)
@property
def is_rw(self):
return self.role in (Member.Role.REGULAR, Member.Role.MANAGER)
class Credential(models.Model):
code = models.UUIDField(default=uuid.uuid4, unique=True)
name = models.CharField(max_length=100)
user = models.ForeignKey(User, models.CASCADE, related_name="credentials")
created = models.DateTimeField(auto_now_add=True)
data = models.BinaryField()
def unpack(self):
unpacked, remaining_data = AttestedCredentialData.unpack_from(self.data)
return unpacked

View File

@ -0,0 +1,17 @@
from hc.accounts.models import Project
from hc.test import BaseTestCase
class AddProjectTestCase(BaseTestCase):
def test_it_works(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.post("/projects/add/", {"name": "My Second Project"})
p = Project.objects.get(owner=self.alice, name="My Second Project")
self.assertRedirects(r, "/projects/%s/checks/" % p.code)
self.assertEqual(str(p.code), p.badge_key)
def test_it_rejects_get(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get("/projects/add/")
self.assertEqual(r.status_code, 405)

View File

@ -0,0 +1,83 @@
from unittest.mock import patch
from hc.test import BaseTestCase
class AddTotpTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.url = "/accounts/two_factor/totp/"
def test_it_requires_sudo_mode(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "We have sent a confirmation code")
def test_it_shows_form(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertContains(r, "Enter the six-digit code")
# It should put a "totp_secret" key in the session:
self.assertIn("totp_secret", self.client.session)
@patch("hc.accounts.views.pyotp.totp.TOTP")
def test_it_adds_totp(self, mock_TOTP):
mock_TOTP.return_value.verify.return_value = True
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {"code": "000000"}
r = self.client.post(self.url, payload, follow=True)
self.assertRedirects(r, "/accounts/profile/")
self.assertContains(r, "Successfully set up the Authenticator app")
# totp_secret should be gone from the session:
self.assertNotIn("totp_secret", self.client.session)
self.profile.refresh_from_db()
self.assertTrue(self.profile.totp)
self.assertTrue(self.profile.totp_created)
@patch("hc.accounts.views.pyotp.totp.TOTP")
def test_it_handles_wrong_code(self, mock_TOTP):
mock_TOTP.return_value.verify.return_value = False
mock_TOTP.return_value.provisioning_uri.return_value = "test-uri"
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {"code": "000000"}
r = self.client.post(self.url, payload, follow=True)
self.assertContains(r, "The code you entered was incorrect.")
self.profile.refresh_from_db()
self.assertIsNone(self.profile.totp)
self.assertIsNone(self.profile.totp_created)
def test_it_checks_if_totp_already_configured(self):
self.profile.totp = "0" * 32
self.profile.save()
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 400)
@patch("hc.accounts.views.pyotp.totp.TOTP")
def test_it_handles_non_numeric_code(self, mock_TOTP):
mock_TOTP.return_value.verify.return_value = False
mock_TOTP.return_value.provisioning_uri.return_value = "test-uri"
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {"code": "AAAAAA"}
r = self.client.post(self.url, payload, follow=True)
self.assertContains(r, "Enter a valid value")

View File

@ -0,0 +1,98 @@
from unittest.mock import patch
from django.test.utils import override_settings
from hc.test import BaseTestCase
from hc.accounts.models import Credential
@override_settings(RP_ID="testserver")
class AddWebauthnTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.url = "/accounts/two_factor/webauthn/"
def test_it_requires_sudo_mode(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "We have sent a confirmation code")
@override_settings(RP_ID=None)
def test_it_requires_rp_id(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
def test_it_shows_form(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertContains(r, "Add Security Key")
# It should put a "state" key in the session:
self.assertIn("state", self.client.session)
@patch("hc.accounts.views._get_credential_data")
def test_it_adds_credential(self, mock_get_credential_data):
mock_get_credential_data.return_value = b"dummy-credential-data"
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {
"name": "My New Key",
"client_data_json": "e30=",
"attestation_object": "e30=",
}
r = self.client.post(self.url, payload, follow=True)
self.assertRedirects(r, "/accounts/profile/")
self.assertContains(r, "Added security key <strong>My New Key</strong>")
c = Credential.objects.get()
self.assertEqual(c.name, "My New Key")
def test_it_rejects_bad_base64(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {
"name": "My New Key",
"client_data_json": "not valid base64",
"attestation_object": "not valid base64",
}
r = self.client.post(self.url, payload)
self.assertEqual(r.status_code, 400)
def test_it_requires_client_data_json(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {
"name": "My New Key",
"attestation_object": "e30=",
}
r = self.client.post(self.url, payload)
self.assertEqual(r.status_code, 400)
@patch("hc.accounts.views._get_credential_data")
def test_it_handles_authentication_failure(self, mock_get_credential_data):
mock_get_credential_data.return_value = None
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {
"name": "My New Key",
"client_data_json": "e30=",
"attestation_object": "e30=",
}
r = self.client.post(self.url, payload, follow=True)
self.assertEqual(r.status_code, 400)

View File

@ -2,9 +2,8 @@ from hc.test import BaseTestCase
class AccountsAdminTestCase(BaseTestCase):
def setUp(self):
super(AccountsAdminTestCase, self).setUp()
super().setUp()
self.alice.is_staff = True
self.alice.is_superuser = True

View File

@ -1,20 +0,0 @@
from hc.test import BaseTestCase
from hc.api.models import Check
class BadgesTestCase(BaseTestCase):
def test_it_shows_badges(self):
self.client.login(username="alice@example.org", password="password")
Check.objects.create(user=self.alice, tags="foo a-B_1 baz@")
Check.objects.create(user=self.bob, tags="bobs-tag")
r = self.client.get("/accounts/profile/badges/")
self.assertContains(r, "foo.svg")
self.assertContains(r, "a-B_1.svg")
# Expect badge URLs only for tags that match \w+
self.assertNotContains(r, "baz@.svg")
# Expect only Alice's tags
self.assertNotContains(r, "bobs-tag.svg")

View File

@ -1,41 +1,43 @@
from django.contrib.auth.hashers import make_password
from hc.test import BaseTestCase
class ChangeEmailTestCase(BaseTestCase):
def test_it_requires_sudo_mode(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get("/accounts/change_email/")
self.assertContains(r, "We have sent a confirmation code")
def test_it_shows_form(self):
self.profile.token = make_password("foo", "change-email")
self.profile.save()
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get("/accounts/change_email/foo/")
r = self.client.get("/accounts/change_email/")
self.assertContains(r, "Change Account's Email Address")
def test_it_changes_password(self):
self.profile.token = make_password("foo", "change-email")
self.profile.save()
def test_it_updates_email(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {"email": "alice2@example.org"}
self.client.post("/accounts/change_email/foo/", payload)
r = self.client.post("/accounts/change_email/", payload, follow=True)
self.assertRedirects(r, "/accounts/change_email/done/")
self.assertContains(r, "Email Address Updated")
self.alice.refresh_from_db()
self.assertEqual(self.alice.email, "alice2@example.org")
self.assertFalse(self.alice.has_usable_password())
def test_it_requires_unique_email(self):
self.profile.token = make_password("foo", "change-email")
self.profile.save()
# The user should have been logged out:
self.assertNotIn("_auth_user_id", self.client.session)
def test_it_requires_unique_email(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {"email": "bob@example.org"}
r = self.client.post("/accounts/change_email/foo/", payload)
self.assertContains(r, "bob@example.org is not available")
r = self.client.post("/accounts/change_email/", payload)
self.assertContains(r, "bob@example.org is already registered")
self.alice.refresh_from_db()
self.assertEqual(self.alice.email, "alice@example.org")

View File

@ -1,21 +1,24 @@
from django.contrib.auth.hashers import make_password
from hc.accounts.models import Credential
from hc.test import BaseTestCase
class CheckTokenTestCase(BaseTestCase):
def setUp(self):
super(CheckTokenTestCase, self).setUp()
super().setUp()
self.profile.token = make_password("secret-token", "login")
self.profile.save()
self.checks_url = "/projects/%s/checks/" % self.project.code
def test_it_shows_form(self):
r = self.client.get("/accounts/check_token/alice/secret-token/")
self.assertContains(r, "You are about to log in")
def test_it_redirects(self):
r = self.client.post("/accounts/check_token/alice/secret-token/")
self.assertRedirects(r, "/checks/")
self.assertRedirects(r, self.checks_url)
# After login, token should be blank
self.profile.refresh_from_db()
@ -27,7 +30,8 @@ class CheckTokenTestCase(BaseTestCase):
# Login again, when already authenticated
r = self.client.post("/accounts/check_token/alice/secret-token/")
self.assertRedirects(r, "/checks/")
self.assertRedirects(r, self.checks_url)
def test_it_redirects_bad_login(self):
# Login with a bad token
@ -35,3 +39,28 @@ class CheckTokenTestCase(BaseTestCase):
r = self.client.post(url, follow=True)
self.assertRedirects(r, "/accounts/login/")
self.assertContains(r, "incorrect or expired")
def test_it_handles_next_parameter(self):
url = "/accounts/check_token/alice/secret-token/?next=" + self.channels_url
r = self.client.post(url)
self.assertRedirects(r, self.channels_url)
def test_it_ignores_bad_next_parameter(self):
url = "/accounts/check_token/alice/secret-token/?next=/evil/"
r = self.client.post(url)
self.assertRedirects(r, self.checks_url)
def test_it_redirects_to_webauthn_form(self):
Credential.objects.create(user=self.alice, name="Alices Key")
r = self.client.post("/accounts/check_token/alice/secret-token/")
self.assertRedirects(
r, "/accounts/login/two_factor/", fetch_redirect_response=False
)
# It should not log the user in yet
self.assertNotIn("_auth_user_id", self.client.session)
# Instead, it should set 2fa_user_id in the session
user_id, email, valid_until = self.client.session["2fa_user"]
self.assertEqual(user_id, self.alice.id)

View File

@ -1,57 +1,79 @@
from unittest.mock import patch
from django.contrib.auth.models import User
from hc.api.models import Check
from hc.payments.models import Subscription
from hc.test import BaseTestCase
from mock import patch
class CloseAccountTestCase(BaseTestCase):
def test_it_requires_sudo_mode(self):
self.client.login(username="alice@example.org", password="password")
@patch("hc.payments.models.Subscription.cancel")
def test_it_works(self, mock_cancel):
Check.objects.create(user=self.alice, tags="foo a-B_1 baz@")
Subscription.objects.create(user=self.alice, subscription_id="123")
r = self.client.get("/accounts/close/")
self.assertContains(r, "We have sent a confirmation code")
def test_it_shows_confirmation_form(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get("/accounts/close/")
self.assertContains(r, "Close Account?")
self.assertContains(r, "1 project")
self.assertContains(r, "0 checks")
@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", customer_id="fake-customer-id"
)
self.client.login(username="alice@example.org", password="password")
r = self.client.post("/accounts/close/")
self.assertEqual(r.status_code, 302)
self.set_sudo_flag()
payload = {"confirmation": "alice@example.org"}
r = self.client.post("/accounts/close/", payload)
self.assertRedirects(r, "/")
# Alice should be gone
alices = User.objects.filter(username="alice")
self.assertFalse(alices.exists())
# Alice should be gone
alices = User.objects.filter(username="alice")
self.assertFalse(alices.exists())
# Bob's current team should now be None
self.bobs_profile.refresh_from_db()
self.assertIsNone(self.bobs_profile.current_team)
# Check should be gone
self.assertFalse(Check.objects.exists())
# Subscription should have been canceled
self.assertTrue(mock_cancel.called)
self.assertTrue(mock_braintree.Subscription.cancel.called)
# Subscription should be gone
self.assertFalse(Subscription.objects.exists())
def test_partner_removal_works(self):
self.client.login(username="bob@example.org", password="password")
r = self.client.post("/accounts/close/")
self.assertEqual(r.status_code, 302)
def test_it_requires_confirmation(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {"confirmation": "incorrect"}
r = self.client.post("/accounts/close/", payload)
self.assertContains(r, "Close Account?")
self.assertContains(r, "has-error")
# Alice should be still present
self.alice.refresh_from_db()
self.profile.refresh_from_db()
def test_partner_removal_works(self):
self.client.login(username="bob@example.org", password="password")
self.set_sudo_flag()
payload = {"confirmation": "bob@example.org"}
r = self.client.post("/accounts/close/", payload)
self.assertRedirects(r, "/")
# Alice should be still present
self.alice.refresh_from_db()
self.profile.refresh_from_db()
self.assertEqual(self.profile.current_team, None)
# Bob should be gone
bobs = User.objects.filter(username="bob")
self.assertFalse(bobs.exists())
def test_it_rejects_get(self):
self.client.login(username="bob@example.org", password="password")
r = self.client.get("/accounts/close/")
self.assertEqual(r.status_code, 405)

View File

@ -1,84 +1,163 @@
from django.contrib.auth.models import User
from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
from hc.accounts.models import Profile
from hc.api.models import Check
from django.conf import settings
from django.core import mail
from django.test.utils import override_settings
from hc.accounts.models import Credential
from hc.api.models import Check, TokenBucket
from hc.test import BaseTestCase
class LoginTestCase(TestCase):
class LoginTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.checks_url = f"/projects/{self.project.code}/checks/"
def test_it_shows_form(self):
r = self.client.get("/accounts/login/")
self.assertContains(r, "Email Me a Link")
def test_it_redirects_authenticated_get(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get("/accounts/login/")
self.assertRedirects(r, self.checks_url)
def test_it_sends_link(self):
check = Check()
check.save()
session = self.client.session
session["welcome_code"] = str(check.code)
session.save()
form = {"email": "alice@example.org"}
form = {"identity": "alice@example.org"}
r = self.client.post("/accounts/login/", form)
assert r.status_code == 302
self.assertRedirects(r, "/accounts/login_link_sent/")
# An user should have been created
self.assertEqual(User.objects.count(), 1)
# And email sent
# And email should have been sent
self.assertEqual(len(mail.outbox), 1)
subject = "Log in to %s" % settings.SITE_NAME
self.assertEqual(mail.outbox[0].subject, subject)
# And check should be associated with the new user
check_again = Check.objects.get(code=check.code)
assert check_again.user
def test_it_sends_link_with_next(self):
form = {"identity": "alice@example.org"}
r = self.client.post("/accounts/login/?next=" + self.channels_url, form)
self.assertRedirects(r, "/accounts/login_link_sent/")
self.assertIn("auto-login", r.cookies)
# The check_token link should have a ?next= query parameter:
self.assertEqual(len(mail.outbox), 1)
body = mail.outbox[0].body
self.assertTrue("/?next=" + self.channels_url in body)
@override_settings(SECRET_KEY="test-secret")
def test_it_rate_limits_emails(self):
# "d60d..." is sha1("alice@example.orgtest-secret")
obj = TokenBucket(value="em-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
obj.tokens = 0
obj.save()
form = {"identity": "alice@example.org"}
r = self.client.post("/accounts/login/", form)
self.assertContains(r, "Too many attempts")
# No email should have been sent
self.assertEqual(len(mail.outbox), 0)
def test_it_pops_bad_link_from_session(self):
self.client.session["bad_link"] = True
self.client.get("/accounts/login/")
assert "bad_link" not in self.client.session
def test_it_handles_missing_welcome_check(self):
# This check does not exist in database,
# but login should still work.
session = self.client.session
session["welcome_code"] = "00000000-0000-0000-0000-000000000000"
session.save()
form = {"email": "alice@example.org"}
def test_it_ignores_case(self):
form = {"identity": "ALICE@EXAMPLE.ORG"}
r = self.client.post("/accounts/login/", form)
assert r.status_code == 302
self.assertRedirects(r, "/accounts/login_link_sent/")
# An user should have been created
self.assertEqual(User.objects.count(), 1)
self.profile.refresh_from_db()
self.assertIn("login", self.profile.token)
# And email sent
self.assertEqual(len(mail.outbox), 1)
subject = "Log in to %s" % settings.SITE_NAME
self.assertEqual(mail.outbox[0].subject, subject)
def test_it_handles_password(self):
form = {"action": "login", "email": "alice@example.org", "password": "password"}
r = self.client.post("/accounts/login/", form)
self.assertRedirects(r, self.checks_url)
@override_settings(SECRET_KEY="test-secret")
def test_it_rate_limits_password_attempts(self):
# "d60d..." is sha1("alice@example.orgtest-secret")
obj = TokenBucket(value="pw-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
obj.tokens = 0
obj.save()
form = {"action": "login", "email": "alice@example.org", "password": "password"}
r = self.client.post("/accounts/login/", form)
self.assertContains(r, "Too many attempts")
def test_it_handles_password_login_with_redirect(self):
check = Check.objects.create(project=self.project)
form = {"action": "login", "email": "alice@example.org", "password": "password"}
samples = [self.channels_url, "/checks/%s/details/" % check.code]
for s in samples:
r = self.client.post("/accounts/login/?next=%s" % s, form)
self.assertRedirects(r, s)
def test_it_handles_bad_next_parameter(self):
form = {"action": "login", "email": "alice@example.org", "password": "password"}
samples = [
"/evil/",
f"https://example.org/projects/{self.project.code}/checks/",
]
for sample in samples:
r = self.client.post("/accounts/login/?next=" + sample, form)
self.assertRedirects(r, self.checks_url)
def test_it_handles_wrong_password(self):
form = {
"action": "login",
"email": "alice@example.org",
"password": "wrong password",
}
r = self.client.post("/accounts/login/", form)
self.assertContains(r, "Incorrect email or password")
@override_settings(REGISTRATION_OPEN=False)
def test_it_obeys_registration_open(self):
form = {"email": "dan@example.org"}
r = self.client.get("/accounts/login/")
self.assertNotContains(r, "Create Your Account")
def test_it_redirects_to_webauthn_form(self):
Credential.objects.create(user=self.alice, name="Alices Key")
form = {"action": "login", "email": "alice@example.org", "password": "password"}
r = self.client.post("/accounts/login/", form)
assert r.status_code == 200
self.assertContains(r, "Incorrect email")
self.assertRedirects(
r, "/accounts/login/two_factor/", fetch_redirect_response=False
)
def test_it_ignores_ces(self):
alice = User(username="alice", email="alice@example.org")
alice.save()
# It should not log the user in yet
self.assertNotIn("_auth_user_id", self.client.session)
form = {"email": "ALICE@EXAMPLE.ORG"}
# Instead, it should set 2fa_user_id in the session
user_id, email, valid_until = self.client.session["2fa_user"]
self.assertEqual(user_id, self.alice.id)
def test_it_redirects_to_totp_form(self):
self.profile.totp = "0" * 32
self.profile.save()
form = {"action": "login", "email": "alice@example.org", "password": "password"}
r = self.client.post("/accounts/login/", form)
assert r.status_code == 302
self.assertRedirects(
r, "/accounts/login/two_factor/totp/", fetch_redirect_response=False
)
# There should be exactly one user:
self.assertEqual(User.objects.count(), 1)
# It should not log the user in yet
self.assertNotIn("_auth_user_id", self.client.session)
profile = Profile.objects.for_user(alice)
self.assertIn("login", profile.token)
# Instead, it should set 2fa_user_id in the session
user_id, email, valid_until = self.client.session["2fa_user"]
self.assertEqual(user_id, self.alice.id)

View File

@ -0,0 +1,97 @@
import time
from unittest.mock import patch
from hc.api.models import TokenBucket
from hc.test import BaseTestCase
class LoginTotpTestCase(BaseTestCase):
def setUp(self):
super().setUp()
# This is the user we're trying to authenticate
session = self.client.session
session["2fa_user"] = [self.alice.id, self.alice.email, (time.time()) + 300]
session.save()
self.profile.totp = "0" * 32
self.profile.save()
self.url = "/accounts/login/two_factor/totp/"
self.checks_url = f"/projects/{self.project.code}/checks/"
def test_it_shows_form(self):
r = self.client.get(self.url)
self.assertContains(r, "Please enter the six-digit code")
def test_it_requires_unauthenticated_user(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 400)
def test_it_requires_totp_secret(self):
self.profile.totp = None
self.profile.save()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 400)
def test_it_rejects_changed_email(self):
session = self.client.session
session["2fa_user"] = [self.alice.id, "eve@example.org", int(time.time())]
session.save()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 400)
def test_it_rejects_old_timestamp(self):
session = self.client.session
session["2fa_user"] = [self.alice.id, self.alice.email, int(time.time()) - 310]
session.save()
r = self.client.get(self.url)
self.assertRedirects(r, "/accounts/login/")
@patch("hc.accounts.views.pyotp.totp.TOTP")
def test_it_logs_in(self, mock_TOTP):
mock_TOTP.return_value.verify.return_value = True
r = self.client.post(self.url, {"code": "000000"})
self.assertRedirects(r, self.checks_url)
self.assertNotIn("2fa_user_id", self.client.session)
@patch("hc.accounts.views.pyotp.totp.TOTP")
def test_it_redirects_after_login(self, mock_TOTP):
mock_TOTP.return_value.verify.return_value = True
url = self.url + "?next=" + self.channels_url
r = self.client.post(url, {"code": "000000"})
self.assertRedirects(r, self.channels_url)
@patch("hc.accounts.views.pyotp.totp.TOTP")
def test_it_handles_authentication_failure(self, mock_TOTP):
mock_TOTP.return_value.verify.return_value = False
r = self.client.post(self.url, {"code": "000000"})
self.assertContains(r, "The code you entered was incorrect.")
def test_it_uses_rate_limiting(self):
obj = TokenBucket(value=f"totp-{self.alice.id}")
obj.tokens = 0
obj.save()
r = self.client.post(self.url, {"code": "000000"})
self.assertContains(r, "Too Many Requests")
@patch("hc.accounts.views.pyotp.totp.TOTP")
def test_it_rejects_used_code(self, mock_TOTP):
mock_TOTP.return_value.verify.return_value = True
obj = TokenBucket(value=f"totpc-{self.alice.id}-000000")
obj.tokens = 0
obj.save()
r = self.client.post(self.url, {"code": "000000"})
self.assertContains(r, "Too Many Requests")

View File

@ -0,0 +1,149 @@
import time
from unittest.mock import patch
from django.test.utils import override_settings
from hc.test import BaseTestCase
@override_settings(RP_ID="testserver")
class LoginWebAuthnTestCase(BaseTestCase):
def setUp(self):
super().setUp()
# This is the user we're trying to authenticate
session = self.client.session
session["2fa_user"] = [self.alice.id, self.alice.email, (time.time()) + 300]
session.save()
self.url = "/accounts/login/two_factor/"
self.checks_url = f"/projects/{self.project.code}/checks/"
def test_it_shows_form(self):
r = self.client.get(self.url)
self.assertContains(r, "Waiting for security key")
self.assertNotContains(r, "Use authenticator app")
# It should put a "state" key in the session:
self.assertIn("state", self.client.session)
def test_it_shows_totp_option(self):
self.profile.totp = "0" * 32
self.profile.save()
r = self.client.get(self.url)
self.assertContains(r, "Use authenticator app")
def test_it_preserves_next_parameter_in_totp_url(self):
self.profile.totp = "0" * 32
self.profile.save()
url = self.url + "?next=" + self.channels_url
r = self.client.get(url)
self.assertContains(r, "/login/two_factor/totp/?next=" + self.channels_url)
def test_it_requires_unauthenticated_user(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 400)
def test_it_rejects_changed_email(self):
session = self.client.session
session["2fa_user"] = [self.alice.id, "eve@example.org", int(time.time())]
session.save()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 400)
def test_it_rejects_old_timestamp(self):
session = self.client.session
session["2fa_user"] = [self.alice.id, self.alice.email, int(time.time()) - 310]
session.save()
r = self.client.get(self.url)
self.assertRedirects(r, "/accounts/login/")
@override_settings(RP_ID=None)
def test_it_requires_rp_id(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 500)
@patch("hc.accounts.views._check_credential")
def test_it_logs_in(self, mock_check_credential):
mock_check_credential.return_value = True
session = self.client.session
session["state"] = "dummy-state"
session.save()
payload = {
"name": "My New Key",
"credential_id": "e30=",
"client_data_json": "e30=",
"authenticator_data": "e30=",
"signature": "e30=",
}
r = self.client.post(self.url, payload)
self.assertRedirects(r, self.checks_url)
self.assertNotIn("state", self.client.session)
self.assertNotIn("2fa_user_id", self.client.session)
@patch("hc.accounts.views._check_credential")
def test_it_redirects_after_login(self, mock_check_credential):
mock_check_credential.return_value = True
session = self.client.session
session["state"] = "dummy-state"
session.save()
payload = {
"name": "My New Key",
"credential_id": "e30=",
"client_data_json": "e30=",
"authenticator_data": "e30=",
"signature": "e30=",
}
url = self.url + "?next=" + self.channels_url
r = self.client.post(url, payload)
self.assertRedirects(r, self.channels_url)
@patch("hc.accounts.views._check_credential")
def test_it_handles_bad_base64(self, mock_check_credential):
mock_check_credential.return_value = None
session = self.client.session
session["state"] = "dummy-state"
session.save()
payload = {
"name": "My New Key",
"credential_id": "this is not base64 data",
"client_data_json": "e30=",
"authenticator_data": "e30=",
"signature": "e30=",
}
r = self.client.post(self.url, payload)
self.assertEqual(r.status_code, 400)
@patch("hc.accounts.views._check_credential")
def test_it_handles_authentication_failure(self, mock_check_credential):
mock_check_credential.return_value = None
session = self.client.session
session["state"] = "dummy-state"
session.save()
payload = {
"name": "My New Key",
"credential_id": "e30=",
"client_data_json": "e30=",
"authenticator_data": "e30=",
"signature": "e30=",
}
r = self.client.post(self.url, payload)
self.assertEqual(r.status_code, 400)

View File

@ -1,60 +1,113 @@
from datetime import timedelta as td
from django.utils.timezone import now
from hc.api.models import Check
from hc.test import BaseTestCase
class NotificationsTestCase(BaseTestCase):
url = "/accounts/profile/notifications/"
def test_it_saves_reports_allowed_true(self):
self.profile.reports_allowed = False
def _payload(self, **kwargs):
result = {"reports": "monthly", "nag_period": "0", "tz": "Europe/Riga"}
result.update(kwargs)
return result
def test_it_saves_reports_monthly(self):
self.profile.reports = "off"
self.profile.save()
self.client.login(username="alice@example.org", password="password")
form = {"reports_allowed": "on", "nag_period": "0"}
r = self.client.post("/accounts/profile/notifications/", form)
r = self.client.post(self.url, self._payload())
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertTrue(self.profile.reports_allowed)
self.assertIsNotNone(self.profile.next_report_date)
self.assertEqual(self.profile.reports, "monthly")
self.assertEqual(self.profile.next_report_date.day, 1)
def test_it_saves_reports_allowed_false(self):
self.profile.reports_allowed = True
def test_it_saves_reports_weekly(self):
self.profile.reports = "off"
self.profile.save()
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, self._payload(reports="weekly"))
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertEqual(self.profile.reports, "weekly")
self.assertEqual(self.profile.next_report_date.weekday(), 0)
def test_it_saves_reports_off(self):
self.profile.reports = "monthly"
self.profile.next_report_date = now()
self.profile.save()
self.client.login(username="alice@example.org", password="password")
form = {"nag_period": "0"}
r = self.client.post("/accounts/profile/notifications/", form)
r = self.client.post(self.url, self._payload(reports="off"))
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertFalse(self.profile.reports_allowed)
self.assertEqual(self.profile.reports, "off")
self.assertIsNone(self.profile.next_report_date)
def test_it_saves_hourly_nag_period(self):
def test_it_sets_next_nag_date_when_setting_hourly_nag_period(self):
Check.objects.create(project=self.project, status="down")
self.client.login(username="alice@example.org", password="password")
form = {"nag_period": "3600"}
r = self.client.post("/accounts/profile/notifications/", form)
r = self.client.post(self.url, self._payload(nag_period="3600"))
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
self.assertIsNotNone(self.profile.next_nag_date)
def test_it_clears_next_nag_date_when_setting_hourly_nag_period(self):
self.profile.next_nag_date = now() + td(minutes=30)
self.profile.save()
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, self._payload(nag_period="3600"))
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
self.assertIsNone(self.profile.next_nag_date)
def test_it_does_not_save_nonstandard_nag_period(self):
self.profile.nag_period = td(seconds=3600)
self.profile.save()
self.client.login(username="alice@example.org", password="password")
form = {"nag_period": "1234"}
r = self.client.post("/accounts/profile/notifications/", form)
r = self.client.post(self.url, self._payload(nag_period="1234"))
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
def test_it_saves_tz(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, self._payload())
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertEqual(self.profile.tz, "Europe/Riga")
def test_it_ignores_bad_tz(self):
self.profile.tz = "Europe/Riga"
self.profile.save()
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, self._payload(reports="weekly", tz="Foo/Bar"))
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertEqual(self.profile.reports, "weekly")
self.assertEqual(self.profile.tz, "Europe/Riga")

View File

@ -1,174 +1,105 @@
from datetime import timedelta as td
from django.core import mail
from django.conf import settings
from django.utils.timezone import now
from django.test.utils import override_settings
from hc.test import BaseTestCase
from hc.accounts.models import Member
from hc.api.models import Check
from hc.accounts.models import Credential
class ProfileTestCase(BaseTestCase):
def test_it_sends_set_password_link(self):
def test_it_shows_profile_page(self):
self.client.login(username="alice@example.org", password="password")
form = {"set_password": "1"}
r = self.client.post("/accounts/profile/", form)
assert r.status_code == 302
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Email and Password")
self.assertContains(r, "Change Password")
self.assertContains(r, "Set Up Authenticator App")
# profile.token should be set now
self.profile.refresh_from_db()
token = self.profile.token
self.assertTrue(len(token) > 10)
# And an email should have been sent
self.assertEqual(len(mail.outbox), 1)
expected_subject = "Set password on %s" % settings.SITE_NAME
self.assertEqual(mail.outbox[0].subject, expected_subject)
def test_it_creates_api_key(self):
self.client.login(username="alice@example.org", password="password")
form = {"create_api_key": "1"}
r = self.client.post("/accounts/profile/", form)
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
api_key = self.profile.api_key
self.assertTrue(len(api_key) > 10)
def test_it_revokes_api_key(self):
self.client.login(username="alice@example.org", password="password")
form = {"revoke_api_key": "1"}
r = self.client.post("/accounts/profile/", form)
assert r.status_code == 200
self.profile.refresh_from_db()
self.assertEqual(self.profile.api_key, "")
def test_it_sends_report(self):
check = Check(name="Test Check", user=self.alice)
check.last_ping = now()
check.save()
sent = self.profile.send_report()
self.assertTrue(sent)
# And an email should have been sent
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual(message.subject, 'Monthly Report')
self.assertIn("Test Check", message.body)
def test_it_sends_nag(self):
check = Check(name="Test Check", user=self.alice)
check.status = "down"
check.last_ping = now()
check.save()
self.profile.nag_period = td(hours=1)
self.profile.save()
sent = self.profile.send_report(nag=True)
self.assertTrue(sent)
# And an email should have been sent
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual(message.subject, 'Reminder: 1 check still down')
self.assertIn("Test Check", message.body)
def test_it_skips_nag_if_none_down(self):
check = Check(name="Test Check", user=self.alice)
check.last_ping = now()
check.save()
self.profile.nag_period = td(hours=1)
self.profile.save()
sent = self.profile.send_report(nag=True)
self.assertFalse(sent)
self.assertEqual(len(mail.outbox), 0)
def test_it_adds_team_member(self):
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "frank@example.org"}
r = self.client.post("/accounts/profile/", form)
self.assertEqual(r.status_code, 200)
member_emails = set()
for member in self.profile.member_set.all():
member_emails.add(member.user.email)
self.assertEqual(len(member_emails), 2)
self.assertTrue("frank@example.org" in member_emails)
# And an email should have been sent
subj = ('You have been invited to join'
' alice@example.org on %s' % settings.SITE_NAME)
self.assertEqual(mail.outbox[0].subject, subj)
def test_it_checks_team_size(self):
self.profile.team_limit = 0
self.profile.save()
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "frank@example.org"}
r = self.client.post("/accounts/profile/", form)
self.assertEqual(r.status_code, 403)
def test_it_removes_team_member(self):
self.client.login(username="alice@example.org", password="password")
form = {"remove_team_member": "1", "email": "bob@example.org"}
r = self.client.post("/accounts/profile/", form)
self.assertEqual(r.status_code, 200)
self.assertEqual(Member.objects.count(), 0)
self.bobs_profile.refresh_from_db()
self.assertEqual(self.bobs_profile.current_team, None)
def test_it_sets_team_name(self):
self.client.login(username="alice@example.org", password="password")
form = {"set_team_name": "1", "team_name": "Alpha Team"}
r = self.client.post("/accounts/profile/", form)
self.assertEqual(r.status_code, 200)
self.profile.refresh_from_db()
self.assertEqual(self.profile.team_name, "Alpha Team")
def test_it_switches_to_own_team(self):
def test_leaving_works(self):
self.client.login(username="bob@example.org", password="password")
self.client.get("/accounts/profile/")
form = {"code": str(self.project.code), "leave_project": "1"}
r = self.client.post("/accounts/profile/", form)
self.assertContains(r, "Left project <strong>Alices Project</strong>")
self.assertNotContains(r, "Member")
# After visiting the profile page, team should be switched back
# to user's default team.
self.bobs_profile.refresh_from_db()
self.assertEqual(self.bobs_profile.current_team, self.bobs_profile)
self.assertFalse(self.bob.memberships.exists())
def test_leaving_checks_membership(self):
self.client.login(username="charlie@example.org", password="password")
form = {"code": str(self.project.code), "leave_project": "1"}
r = self.client.post("/accounts/profile/", form)
self.assertEqual(r.status_code, 400)
def test_it_shows_project_membership(self):
self.client.login(username="bob@example.org", password="password")
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Alices Project")
self.assertContains(r, "Member")
def test_it_shows_readonly_project_membership(self):
self.bobs_membership.role = "r"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Alices Project")
self.assertContains(r, "Read-only")
def test_it_handles_no_projects(self):
self.project.delete()
def test_it_sends_change_email_link(self):
self.client.login(username="alice@example.org", password="password")
form = {"change_email": "1"}
r = self.client.post("/accounts/profile/", form)
assert r.status_code == 302
r = self.client.get("/accounts/profile/")
self.assertContains(r, "You do not have any projects. Create one!")
# profile.token should be set now
self.profile.refresh_from_db()
token = self.profile.token
self.assertTrue(len(token) > 10)
@override_settings(RP_ID=None)
def test_it_hides_security_keys_bits_if_rp_id_not_set(self):
self.client.login(username="alice@example.org", password="password")
# And an email should have been sent
self.assertEqual(len(mail.outbox), 1)
expected_subject = "Change email address on %s" % settings.SITE_NAME
self.assertEqual(mail.outbox[0].subject, expected_subject)
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Two-factor Authentication")
self.assertNotContains(r, "Security keys")
self.assertNotContains(r, "Add Security Key")
@override_settings(RP_ID="testserver")
def test_it_handles_no_credentials(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Two-factor Authentication")
self.assertContains(r, "Your account does not have any configured two-factor")
@override_settings(RP_ID="testserver")
def test_it_shows_security_key(self):
Credential.objects.create(user=self.alice, name="Alices Key")
self.client.login(username="alice@example.org", password="password")
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Alices Key")
def test_it_handles_unusable_password(self):
self.alice.set_unusable_password()
self.alice.save()
# Authenticate using the ProfileBackend and a token:
token = self.profile.prepare_token("login")
self.client.login(username="alice", token=token)
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Set Password")
self.assertNotContains(r, "Change Password")
def test_it_shows_totp(self):
self.profile.totp = "0" * 32
self.profile.totp_created = "2020-01-01T00:00:00+00:00"
self.profile.save()
self.client.login(username="alice@example.org", password="password")
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Enabled")
self.assertContains(r, "configured on Jan 1, 2020")
self.assertNotContains(r, "Set Up Authenticator App")

View File

@ -0,0 +1,114 @@
from datetime import datetime, timedelta as td
from unittest.mock import Mock, patch
from django.core import mail
from django.utils.timezone import now, utc
from hc.test import BaseTestCase
from hc.api.models import Check
CURRENT_TIME = datetime(2020, 1, 15, tzinfo=utc)
MOCK_NOW = Mock(return_value=CURRENT_TIME)
class ProfileModelTestCase(BaseTestCase):
@patch("hc.lib.date.timezone.now", MOCK_NOW)
def test_it_sends_report(self):
check = Check(project=self.project, name="Test Check")
check.last_ping = now()
check.save()
sent = self.profile.send_report()
self.assertTrue(sent)
# And an email should have been sent
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual(message.subject, "Monthly Report")
self.assertIn("Test Check", message.body)
html, _ = message.alternatives[0]
self.assertNotIn("Jan. 2020", html)
self.assertIn("Dec. 2019", html)
self.assertIn("Nov. 2019", html)
self.assertNotIn("Oct. 2019", html)
def test_it_skips_report_if_no_pings(self):
check = Check(project=self.project, name="Test Check")
check.save()
sent = self.profile.send_report()
self.assertFalse(sent)
self.assertEqual(len(mail.outbox), 0)
def test_it_skips_report_if_no_recent_pings(self):
check = Check(project=self.project, name="Test Check")
check.last_ping = now() - td(days=365)
check.save()
sent = self.profile.send_report()
self.assertFalse(sent)
self.assertEqual(len(mail.outbox), 0)
def test_it_sends_nag(self):
check = Check(project=self.project, name="Test Check")
check.status = "down"
check.last_ping = now()
check.save()
self.profile.nag_period = td(hours=1)
self.profile.save()
sent = self.profile.send_report(nag=True)
self.assertTrue(sent)
# And an email should have been sent
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual(message.subject, "Reminder: 1 check still down")
self.assertIn("Test Check", message.body)
def test_it_skips_nag_if_none_down(self):
check = Check(project=self.project, name="Test Check")
check.last_ping = now()
check.save()
self.profile.nag_period = td(hours=1)
self.profile.save()
sent = self.profile.send_report(nag=True)
self.assertFalse(sent)
self.assertEqual(len(mail.outbox), 0)
def test_it_sets_next_nag_date(self):
Check.objects.create(project=self.project, status="down")
self.profile.nag_period = td(hours=1)
self.profile.update_next_nag_date()
self.assertTrue(self.profile.next_nag_date)
def test_it_does_not_set_next_nag_date_if_no_nag_period(self):
Check.objects.create(project=self.project, status="down")
self.profile.update_next_nag_date()
self.assertIsNone(self.profile.next_nag_date)
def test_it_does_not_update_existing_next_nag_date(self):
Check.objects.create(project=self.project, status="down")
original_nag_date = now() - td(minutes=30)
self.profile.next_nag_date = original_nag_date
self.profile.nag_period = td(hours=1)
self.profile.update_next_nag_date()
self.assertEqual(self.profile.next_nag_date, original_nag_date)
def test_it_clears_next_nag_date(self):
self.profile.next_nag_date = now()
self.profile.update_next_nag_date()
self.assertIsNone(self.profile.next_nag_date)

View File

@ -0,0 +1,352 @@
from django.core import mail
from django.conf import settings
from django.test.utils import override_settings
from hc.test import BaseTestCase
from hc.accounts.models import Member, Project
from hc.api.models import TokenBucket
class ProjectTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.url = "/projects/%s/settings/" % self.project.code
def test_it_checks_access(self):
self.client.login(username="charlie@example.org", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
def test_it_allows_team_access(self):
self.client.login(username="bob@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Change Project Name")
def test_it_shows_api_keys(self):
self.project.api_key_readonly = "R" * 32
self.project.save()
self.client.login(username="alice@example.org", password="password")
form = {"show_api_keys": "1"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "X" * 32)
self.assertContains(r, "R" * 32)
self.assertContains(r, "Prometheus metrics endpoint")
def test_it_creates_api_key(self):
self.client.login(username="alice@example.org", password="password")
form = {"create_api_keys": "1"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
self.project.refresh_from_db()
api_key = self.project.api_key
self.assertTrue(len(api_key) > 10)
self.assertFalse("b'" in api_key)
def test_it_requires_rw_access_to_create_api_key(self):
self.bobs_membership.role = "r"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.post(self.url, {"create_api_keys": "1"})
self.assertEqual(r.status_code, 403)
def test_it_revokes_api_key(self):
self.project.api_key_readonly = "R" * 32
self.project.save()
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, {"revoke_api_keys": "1"})
self.assertEqual(r.status_code, 200)
self.project.refresh_from_db()
self.assertEqual(self.project.api_key, "")
self.assertEqual(self.project.api_key_readonly, "")
def test_it_requires_rw_access_to_revoke_api_key(self):
self.bobs_membership.role = "r"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.post(self.url, {"revoke_api_keys": "1"})
self.assertEqual(r.status_code, 403)
def test_it_adds_team_member(self):
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "w"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
members = self.project.member_set.all()
self.assertEqual(members.count(), 2)
member = Member.objects.get(
project=self.project, user__email="frank@example.org"
)
# The read-write flag should be set
self.assertEqual(member.role, member.Role.REGULAR)
# The new user should not have their own project
self.assertFalse(member.user.project_set.exists())
# And an email should have been sent
subj = f"You have been invited to join Alices Project on {settings.SITE_NAME}"
self.assertHTMLEqual(mail.outbox[0].subject, subj)
def test_it_adds_readonly_team_member(self):
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "r"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
member = Member.objects.get(
project=self.project, user__email="frank@example.org"
)
self.assertEqual(member.role, member.Role.READONLY)
def test_it_adds_manager_team_member(self):
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "m"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
member = Member.objects.get(
project=self.project, user__email="frank@example.org"
)
# The new user should have role manager
self.assertEqual(member.role, member.Role.MANAGER)
def test_it_adds_member_from_another_team(self):
# With team limit at zero, we should not be able to invite any new users
self.profile.team_limit = 0
self.profile.save()
# But Charlie will have an existing membership in another Alice's project
# so Alice *should* be able to invite Charlie:
p2 = Project.objects.create(owner=self.alice)
Member.objects.create(user=self.charlie, project=p2)
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "charlie@example.org", "role": "r"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
q = Member.objects.filter(project=self.project, user=self.charlie)
self.assertEqual(q.count(), 1)
# And this should not have affected the rate limit:
q = TokenBucket.objects.filter(value="invite-%d" % self.alice.id)
self.assertFalse(q.exists())
def test_it_rejects_duplicate_membership(self):
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "bob@example.org", "role": "r"}
r = self.client.post(self.url, form)
self.assertContains(r, "bob@example.org is already a member")
# The number of memberships should have not increased
self.assertEqual(self.project.member_set.count(), 1)
def test_it_rejects_owner_as_a_member(self):
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "alice@example.org", "role": "r"}
r = self.client.post(self.url, form)
self.assertContains(r, "alice@example.org is already a member")
# The number of memberships should have not increased
self.assertEqual(self.project.member_set.count(), 1)
def test_it_rejects_too_long_email_addresses(self):
self.client.login(username="alice@example.org", password="password")
aaa = "a" * 300
form = {
"invite_team_member": "1",
"email": f"frank+{aaa}@example.org",
"role": "r",
}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
# No email should have been sent
self.assertEqual(len(mail.outbox), 0)
@override_settings(SECRET_KEY="test-secret")
def test_it_rate_limits_invites(self):
obj = TokenBucket(value="invite-%d" % self.alice.id)
obj.tokens = 0
obj.save()
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "r"}
r = self.client.post(self.url, form)
self.assertContains(r, "Too Many Requests")
self.assertEqual(len(mail.outbox), 0)
def test_it_lets_manager_add_team_member(self):
# Bob is a manager:
self.bobs_membership.role = "m"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "w"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
Member.objects.get(project=self.project, user__email="frank@example.org")
def test_it_does_not_allow_regular_member_invite_team_members(self):
self.client.login(username="bob@example.org", password="password")
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "w"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 403)
def test_it_checks_team_size(self):
self.profile.team_limit = 0
self.profile.save()
self.client.login(username="alice@example.org", password="password")
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "r"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 403)
def test_it_lets_owner_remove_team_member(self):
self.client.login(username="alice@example.org", password="password")
form = {"remove_team_member": "1", "email": "bob@example.org"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
self.assertFalse(Member.objects.exists())
def test_it_lets_manager_remove_team_member(self):
# Bob is a manager:
self.bobs_membership.role = "m"
self.bobs_membership.save()
# Bob will try to remove this membership:
Member.objects.create(user=self.charlie, project=self.project)
self.client.login(username="bob@example.org", password="password")
form = {"remove_team_member": "1", "email": "charlie@example.org"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
q = Member.objects.filter(user=self.charlie, project=self.project)
self.assertFalse(q.exists())
def test_it_does_not_allow_regular_member_remove_team_member(self):
# Bob will try to remove this membership:
Member.objects.create(user=self.charlie, project=self.project)
self.client.login(username="bob@example.org", password="password")
form = {"remove_team_member": "1", "email": "charlie@example.org"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 403)
def test_it_rejects_manager_remove_self(self):
self.bobs_membership.role = "m"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
form = {"remove_team_member": "1", "email": "bob@example.org"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 400)
# The number of memberships should have not decreased
self.assertEqual(self.project.member_set.count(), 1)
def test_it_checks_membership_when_removing_team_member(self):
self.client.login(username="charlie@example.org", password="password")
url = "/projects/%s/settings/" % self.charlies_project.code
form = {"remove_team_member": "1", "email": "alice@example.org"}
r = self.client.post(url, form)
self.assertEqual(r.status_code, 400)
def test_it_sets_project_name(self):
self.client.login(username="alice@example.org", password="password")
form = {"set_project_name": "1", "name": "Alpha Team"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 200)
self.project.refresh_from_db()
self.assertEqual(self.project.name, "Alpha Team")
def test_it_requires_rw_access_to_set_project_name(self):
self.bobs_membership.role = "r"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
form = {"set_project_name": "1", "name": "Alpha Team"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 403)
def test_it_shows_invite_suggestions(self):
p2 = Project.objects.create(owner=self.alice)
self.client.login(username="alice@example.org", password="password")
r = self.client.get("/projects/%s/settings/" % p2.code)
self.assertContains(r, "Add Users from Other Teams")
self.assertContains(r, "bob@example.org")
def test_it_requires_rw_access_to_update_project_name(self):
self.bobs_membership.role = "r"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
form = {"set_project_name": "1", "name": "Alpha Team"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 403)
def test_it_hides_actions_for_readonly_users(self):
self.bobs_membership.role = "r"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.get(self.url)
self.assertNotContains(r, "#set-project-name-modal", status_code=200)
self.assertNotContains(r, "Show API Keys")
@override_settings(PROMETHEUS_ENABLED=False)
def test_it_hides_prometheus_link_if_prometheus_not_enabled(self):
self.project.api_key_readonly = "R" * 32
self.project.save()
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, {"show_api_keys": "1"})
self.assertEqual(r.status_code, 200)
self.assertNotContains(r, "Prometheus metrics endpoint")
def test_it_requires_rw_access_to_show_api_key(self):
self.bobs_membership.role = "r"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.post(self.url, {"show_api_keys": "1"})
self.assertEqual(r.status_code, 403)

View File

@ -0,0 +1,49 @@
from hc.test import BaseTestCase
from hc.accounts.models import Member, Project
from hc.api.models import Check, Channel
class ProjectModelTestCase(BaseTestCase):
def test_num_checks_available_handles_multiple_projects(self):
# One check in Alice's primary project:
Check.objects.create(project=self.project)
# One check in Alice's secondary project:
p2 = Project.objects.create(owner=self.alice)
Check.objects.create(project=p2)
self.assertEqual(self.project.num_checks_available(), 18)
def test_it_handles_zero_broken_channels(self):
Channel.objects.create(kind="webhook", last_error="", project=self.project)
self.assertFalse(self.project.have_channel_issues())
def test_it_handles_one_broken_channel(self):
Channel.objects.create(kind="webhook", last_error="x", project=self.project)
self.assertTrue(self.project.have_channel_issues())
def test_it_handles_no_channels(self):
# It's an issue if the project has no channels at all:
self.assertTrue(self.project.have_channel_issues())
def test_it_allows_third_user(self):
# Alice is the owner, and Bob is invited -- there is space for the third user:
self.assertTrue(self.project.can_invite_new_users())
def test_it_allows_same_user_in_multiple_projects(self):
p2 = Project.objects.create(owner=self.alice)
Member.objects.create(user=self.bob, project=p2)
# Bob's membership in two projects counts as one seat,
# one seat should be still free:
self.assertTrue(self.project.can_invite_new_users())
def test_it_checks_team_limit(self):
p2 = Project.objects.create(owner=self.alice)
Member.objects.create(user=self.charlie, project=p2)
# Alice and Bob are in one project, Charlie is in another,
# so no seats left:
self.assertFalse(self.project.can_invite_new_users())

View File

@ -1,8 +1,10 @@
from datetime import timedelta
from unittest.mock import Mock
from django.contrib.auth.models import User
from django.utils import timezone
from hc.accounts.management.commands.pruneusers import Command
from hc.accounts.models import Project
from hc.api.models import Check
from hc.test import BaseTestCase
@ -15,26 +17,20 @@ class PruneUsersTestCase(BaseTestCase):
self.charlie.save()
# Charlie has one demo check
Check(user=self.charlie).save()
charlies_project = Project.objects.create(owner=self.charlie)
Check(project=charlies_project).save()
Command().handle()
Command(stdout=Mock()).handle()
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
self.assertEqual(Check.objects.count(), 0)
def test_it_removes_old_users_with_zero_checks(self):
self.charlie.date_joined = self.year_ago
self.charlie.last_login = self.year_ago
self.charlie.save()
Command().handle()
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
def test_it_leaves_team_members_alone(self):
self.bob.date_joined = self.year_ago
self.bob.last_login = self.year_ago
self.bob.save()
Command().handle()
Command(stdout=Mock()).handle()
# Bob belongs to a team so should not get removed
self.assertEqual(User.objects.filter(username="bob").count(), 1)

View File

@ -0,0 +1,56 @@
from unittest.mock import patch
from django.contrib.auth.models import User
from django.test.utils import override_settings
from hc.test import BaseTestCase
@override_settings(
REMOTE_USER_HEADER="AUTH_USER",
AUTHENTICATION_BACKENDS=("hc.accounts.backends.CustomHeaderBackend",),
)
class RemoteUserHeaderTestCase(BaseTestCase):
@override_settings(REMOTE_USER_HEADER=None)
def test_it_does_nothing_when_not_configured(self):
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
def test_it_logs_user_in(self):
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
self.assertContains(r, "alice@example.org")
def test_it_does_nothing_when_header_not_set(self):
r = self.client.get("/accounts/profile/")
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
def test_it_does_nothing_when_header_is_empty_string(self):
r = self.client.get("/accounts/profile/", AUTH_USER="")
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
def test_it_creates_user(self):
r = self.client.get("/accounts/profile/", AUTH_USER="dave@example.org")
self.assertContains(r, "dave@example.org")
q = User.objects.filter(email="dave@example.org")
self.assertTrue(q.exists())
def test_it_logs_out_another_user_when_header_is_empty_string(self):
self.client.login(remote_user_email="bob@example.org")
r = self.client.get("/accounts/profile/", AUTH_USER="")
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
def test_it_logs_out_another_user(self):
self.client.login(remote_user_email="bob@example.org")
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
self.assertContains(r, "alice@example.org")
def test_it_handles_already_logged_in_user(self):
self.client.login(remote_user_email="alice@example.org")
with patch("hc.accounts.middleware.auth") as mock_auth:
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
self.assertFalse(mock_auth.authenticate.called)
self.assertContains(r, "alice@example.org")

View File

@ -0,0 +1,63 @@
from django.test.utils import override_settings
from hc.test import BaseTestCase
from hc.accounts.models import Credential
@override_settings(RP_ID="testserver")
class RemoveCredentialTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.c = Credential.objects.create(user=self.alice, name="Alices Key")
self.url = f"/accounts/two_factor/{self.c.code}/remove/"
def test_it_requires_sudo_mode(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "We have sent a confirmation code")
@override_settings(RP_ID=None)
def test_it_requires_rp_id(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
def test_it_shows_form(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertContains(r, "Remove Security Key")
self.assertContains(r, "Alices Key")
self.assertContains(r, "two-factor authentication will no longer be active")
def test_it_skips_warning_when_other_2fa_methods_exist(self):
self.profile.totp = "0" * 32
self.profile.save()
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertNotContains(r, "two-factor authentication will no longer be active")
def test_it_removes_credential(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.post(self.url, {"remove_credential": ""}, follow=True)
self.assertRedirects(r, "/accounts/profile/")
self.assertContains(r, "Removed security key <strong>Alices Key</strong>")
self.assertFalse(self.alice.credentials.exists())
def test_it_checks_owner(self):
self.client.login(username="charlie@example.org", password="password")
self.set_sudo_flag()
r = self.client.post(self.url, {"remove_credential": ""})
self.assertEqual(r.status_code, 400)

View File

@ -0,0 +1,32 @@
from hc.api.models import Check
from hc.test import BaseTestCase
class RemoveProjectTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.url = "/projects/%s/remove/" % self.project.code
def test_it_works(self):
Check.objects.create(project=self.project, tags="foo a-B_1 baz@")
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url)
self.assertRedirects(r, "/")
# Alice should not own any projects
self.assertFalse(self.alice.project_set.exists())
# Check should be gone
self.assertFalse(Check.objects.exists())
def test_it_rejects_get(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 405)
def test_it_checks_access(self):
self.client.login(username="bob@example.org", password="password")
r = self.client.post(self.url)
self.assertEqual(r.status_code, 404)

View File

@ -0,0 +1,46 @@
from hc.accounts.models import Credential
from hc.test import BaseTestCase
class RemoveCredentialTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.profile.totp = "0" * 32
self.profile.save()
self.url = "/accounts/two_factor/totp/remove/"
def test_it_requires_sudo_mode(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "We have sent a confirmation code")
def test_it_shows_form(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertContains(r, "Disable Authenticator App")
self.assertContains(r, "two-factor authentication will no longer be active")
def test_it_skips_warning_when_other_2fa_methods_exist(self):
self.c = Credential.objects.create(user=self.alice, name="Alices Key")
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertNotContains(r, "two-factor authentication will no longer be active")
def test_it_removes_totp(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.post(self.url, {"disable_totp": "1"}, follow=True)
self.assertRedirects(r, "/accounts/profile/")
self.assertContains(r, "Disabled the authenticator app.")
self.profile.refresh_from_db()
self.assertIsNone(self.profile.totp)
self.assertIsNone(self.profile.totp_created)

View File

@ -0,0 +1,112 @@
from datetime import timedelta as td
import re
from unittest.mock import Mock
from django.core import mail
from django.utils.timezone import now
from hc.accounts.management.commands.senddeletionnotices import Command
from hc.accounts.models import Member
from hc.api.models import Check, Ping
from hc.test import BaseTestCase
def counts(result):
""" Extract integer values from command's return value. """
return [int(s) for s in re.findall(r"\d+", result)]
class SendDeletionNoticesTestCase(BaseTestCase):
def setUp(self):
super().setUp()
# Make alice eligible for notice -- signed up more than 1 year ago
self.alice.date_joined = now() - td(days=500)
self.alice.save()
self.profile.sms_limit = 5
self.profile.save()
# remove members from alice's project
self.project.member_set.all().delete()
def test_it_sends_notice(self):
cmd = Command(stdout=Mock())
cmd.pause = Mock() # don't pause for 1s
result = cmd.handle()
self.assertEqual(counts(result), [1, 0, 0])
self.profile.refresh_from_db()
self.assertTrue(self.profile.deletion_notice_date)
email = mail.outbox[0]
self.assertEqual(email.subject, "Inactive Account Notification")
def test_it_checks_last_login(self):
# alice has logged in recently:
self.alice.last_login = now() - td(days=15)
self.alice.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(counts(result), [0, 0, 0])
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_date_joined(self):
# alice signed up recently:
self.alice.date_joined = now() - td(days=15)
self.alice.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(counts(result), [0, 0, 0])
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_deletion_notice_date(self):
# alice has already received a deletion notice
self.profile.deletion_notice_date = now() - td(days=15)
self.profile.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(counts(result), [0, 0, 0])
def test_it_checks_sms_limit(self):
# alice has a paid account
self.profile.sms_limit = 50
self.profile.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(counts(result), [0, 0, 0])
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_team_members(self):
# bob has access to alice's project
Member.objects.create(user=self.bob, project=self.project)
result = Command(stdout=Mock()).handle()
self.assertEqual(counts(result), [0, 1, 0])
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_recent_pings(self):
check = Check.objects.create(project=self.project)
Ping.objects.create(owner=check)
result = Command(stdout=Mock()).handle()
self.assertEqual(counts(result), [0, 0, 1])
self.profile.refresh_from_db()
self.assertIsNone(self.profile.deletion_notice_date)
def test_it_checks_last_active_date(self):
# alice has been browsing the site recently
self.profile.last_active_date = now() - td(days=15)
self.profile.save()
result = Command(stdout=Mock()).handle()
self.assertEqual(counts(result), [0, 0, 0])

View File

@ -0,0 +1,40 @@
from hc.test import BaseTestCase
class SetPasswordTestCase(BaseTestCase):
def test_it_requires_sudo_mode(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get("/accounts/set_password/")
self.assertContains(r, "We have sent a confirmation code")
def test_it_shows_form(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
r = self.client.get("/accounts/set_password/")
self.assertContains(r, "Please pick a password")
def test_it_sets_password(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {"password": "correct horse battery staple"}
r = self.client.post("/accounts/set_password/", payload)
self.assertRedirects(r, "/accounts/profile/")
old_password = self.alice.password
self.alice.refresh_from_db()
self.assertNotEqual(self.alice.password, old_password)
def test_post_checks_length(self):
self.client.login(username="alice@example.org", password="password")
self.set_sudo_flag()
payload = {"password": "abc"}
r = self.client.post("/accounts/set_password/", payload)
self.assertEqual(r.status_code, 200)
old_password = self.alice.password
self.alice.refresh_from_db()
self.assertEqual(self.alice.password, old_password)

View File

@ -0,0 +1,104 @@
from django.contrib.auth.models import User
from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
from hc.accounts.models import Profile, Project
from hc.api.models import Channel, Check
from django.conf import settings
class SignupTestCase(TestCase):
@override_settings(USE_PAYMENTS=False)
def test_it_works(self):
form = {"identity": "alice@example.org", "tz": "Europe/Riga"}
r = self.client.post("/accounts/signup/", form)
self.assertContains(r, "Account created")
self.assertIn("auto-login", r.cookies)
# An user should have been created
user = User.objects.get()
# A profile should have been created
profile = Profile.objects.get()
self.assertEqual(profile.check_limit, 500)
self.assertEqual(profile.sms_limit, 500)
self.assertEqual(profile.call_limit, 500)
self.assertEqual(profile.tz, "Europe/Riga")
# And email sent
self.assertEqual(len(mail.outbox), 1)
subject = "Log in to %s" % settings.SITE_NAME
self.assertEqual(mail.outbox[0].subject, subject)
# A project should have been created
project = Project.objects.get()
self.assertEqual(project.owner, user)
self.assertEqual(project.badge_key, user.username)
# And check should be associated with the new user
check = Check.objects.get()
self.assertEqual(check.name, "My First Check")
self.assertEqual(check.project, project)
# A channel should have been created
channel = Channel.objects.get()
self.assertEqual(channel.project, project)
@override_settings(USE_PAYMENTS=True)
def test_it_sets_limits(self):
form = {"identity": "alice@example.org", "tz": ""}
self.client.post("/accounts/signup/", form)
profile = Profile.objects.get()
self.assertEqual(profile.check_limit, 20)
self.assertEqual(profile.sms_limit, 5)
self.assertEqual(profile.call_limit, 0)
@override_settings(REGISTRATION_OPEN=False)
def test_it_obeys_registration_open(self):
form = {"identity": "dan@example.org", "tz": ""}
r = self.client.post("/accounts/signup/", form)
self.assertEqual(r.status_code, 403)
def test_it_ignores_case(self):
form = {"identity": "ALICE@EXAMPLE.ORG", "tz": ""}
self.client.post("/accounts/signup/", form)
# There should be exactly one user:
q = User.objects.filter(email="alice@example.org")
self.assertTrue(q.exists)
def test_it_checks_for_existing_users(self):
alice = User(username="alice", email="alice@example.org")
alice.save()
form = {"identity": "alice@example.org", "tz": ""}
r = self.client.post("/accounts/signup/", form)
self.assertContains(r, "already exists")
def test_it_checks_syntax(self):
form = {"identity": "alice at example org", "tz": ""}
r = self.client.post("/accounts/signup/", form)
self.assertContains(r, "Enter a valid email address")
def test_it_checks_length(self):
aaa = "a" * 300
form = {"identity": f"alice+{aaa}@example.org", "tz": ""}
r = self.client.post("/accounts/signup/", form)
self.assertContains(r, "Address is too long.")
self.assertFalse(User.objects.exists())
@override_settings(USE_PAYMENTS=False)
def test_it_ignores_bad_tz(self):
form = {"identity": "alice@example.org", "tz": "Foo/Bar"}
r = self.client.post("/accounts/signup/", form)
self.assertContains(r, "Account created")
self.assertIn("auto-login", r.cookies)
profile = Profile.objects.get()
self.assertEqual(profile.tz, "UTC")

View File

@ -0,0 +1,73 @@
from django.core import mail
from django.core.signing import TimestampSigner
from hc.test import BaseTestCase
from hc.accounts.models import Credential
from hc.api.models import TokenBucket
class SudoModeTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.c = Credential.objects.create(user=self.alice, name="Alices Key")
self.url = f"/accounts/set_password/"
def test_it_sends_code(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "We have sent a confirmation code")
# A code should have been sent
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertEqual(email.to[0], "alice@example.org")
self.assertIn("Confirmation code", email.subject)
def test_it_accepts_code(self):
self.client.login(username="alice@example.org", password="password")
session = self.client.session
session["sudo_code"] = TimestampSigner().sign("123456")
session.save()
r = self.client.post(self.url, {"sudo_code": "123456"})
self.assertRedirects(r, self.url)
# sudo mode should now be active
self.assertIn("sudo", self.client.session)
def test_it_rejects_incorrect_code(self):
self.client.login(username="alice@example.org", password="password")
session = self.client.session
session["sudo_code"] = TimestampSigner().sign("123456")
session.save()
r = self.client.post(self.url, {"sudo_code": "000000"})
self.assertContains(r, "Not a valid code.")
# sudo mode should *not* be active
self.assertNotIn("sudo", self.client.session)
def test_it_passes_through_if_sudo_mode_is_active(self):
self.client.login(username="alice@example.org", password="password")
session = self.client.session
session["sudo"] = TimestampSigner().sign("active")
session.save()
r = self.client.get(self.url)
self.assertContains(r, "Please pick a password")
def test_it_uses_rate_limiting(self):
self.client.login(username="alice@example.org", password="password")
obj = TokenBucket(value=f"sudo-{self.alice.id}")
obj.tokens = 0
obj.save()
r = self.client.get(self.url)
self.assertContains(r, "Too Many Requests")

View File

@ -1,44 +0,0 @@
from hc.test import BaseTestCase
from hc.api.models import Check
class SwitchTeamTestCase(BaseTestCase):
def test_it_switches(self):
c = Check(user=self.alice, name="This belongs to Alice")
c.save()
self.client.login(username="bob@example.org", password="password")
url = "/accounts/switch_team/%s/" % self.alice.username
r = self.client.get(url, follow=True)
self.assertContains(r, "This belongs to Alice")
def test_it_checks_team_membership(self):
self.client.login(username="charlie@example.org", password="password")
url = "/accounts/switch_team/%s/" % self.alice.username
r = self.client.get(url)
self.assertEqual(r.status_code, 403)
def test_it_switches_to_own_team(self):
self.client.login(username="alice@example.org", password="password")
url = "/accounts/switch_team/%s/" % self.alice.username
r = self.client.get(url, follow=True)
self.assertEqual(r.status_code, 200)
def test_it_handles_invalid_username(self):
self.client.login(username="bob@example.org", password="password")
url = "/accounts/switch_team/dave/"
r = self.client.get(url)
self.assertEqual(r.status_code, 403)
def test_it_requires_login(self):
url = "/accounts/switch_team/%s/" % self.alice.username
r = self.client.get(url)
expected_url = "/accounts/login/?next=/accounts/switch_team/alice/"
self.assertRedirects(r, expected_url)

View File

@ -4,7 +4,6 @@ from hc.accounts.models import Profile
class TeamAccessMiddlewareTestCase(TestCase):
def test_it_handles_missing_profile(self):
user = User(username="ned", email="ned@example.org")
user.set_password("password")

View File

@ -0,0 +1,169 @@
from django.core import mail
from django.utils.timezone import now
from hc.accounts.models import Member
from hc.api.models import Check
from hc.test import BaseTestCase
class TransferProjectTestCase(BaseTestCase):
def setUp(self):
super().setUp()
Check.objects.create(project=self.project)
self.url = "/projects/%s/settings/" % self.project.code
def test_transfer_project_works(self):
self.client.login(username="alice@example.org", password="password")
form = {"transfer_project": "1", "email": "bob@example.org"}
r = self.client.post(self.url, form)
self.assertContains(r, "Transfer initiated!")
self.bobs_membership.refresh_from_db()
self.assertIsNotNone(self.bobs_membership.transfer_request_date)
# Bob should receive an email notification
self.assertEqual(len(mail.outbox), 1)
body = mail.outbox[0].body
self.assertTrue("/?next=" + self.url in body)
def test_transfer_project_checks_ownership(self):
self.client.login(username="bob@example.org", password="password")
form = {"transfer_project": "1", "email": "bob@example.org"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 403)
def test_transfer_project_checks_membership(self):
self.client.login(username="alice@example.org", password="password")
form = {"transfer_project": "1", "email": "charlie@example.org"}
r = self.client.post(self.url, form)
self.assertEqual(r.status_code, 400)
def test_cancel_works(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.save()
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, {"cancel_transfer": "1"})
self.assertContains(r, "Transfer cancelled!")
self.bobs_membership.refresh_from_db()
self.assertIsNone(self.bobs_membership.transfer_request_date)
def test_cancel_checks_ownership(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.post(self.url, {"cancel_transfer": "1"})
self.assertEqual(r.status_code, 403)
self.bobs_membership.refresh_from_db()
self.assertIsNotNone(self.bobs_membership.transfer_request_date)
def test_it_shows_transfer_request(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "would like to transfer")
self.assertNotContains(r, "upgrade your account first")
def test_it_shows_transfer_request_with_limit_notice(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.save()
self.bobs_profile.check_limit = 0
self.bobs_profile.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "upgrade your account first")
def test_accept_works(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.post(self.url, {"accept_transfer": "1"})
self.assertContains(r, "You are now the owner of this project!")
self.project.refresh_from_db()
# Bob should now be the owner
self.assertEqual(self.project.owner, self.bob)
# Alice, the previous owner, should now be a member
m = Member.objects.get(project=self.project, user=self.alice)
self.assertEqual(m.role, Member.Role.REGULAR)
def test_accept_requires_a_transfer_request(self):
self.client.login(username="bob@example.org", password="password")
r = self.client.post(self.url, {"accept_transfer": "1"})
self.assertEqual(r.status_code, 403)
self.project.refresh_from_db()
# Alice should still be the owner
self.assertEqual(self.project.owner, self.alice)
def test_only_the_proposed_owner_can_accept(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.save()
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, {"accept_transfer": "1"})
self.assertEqual(r.status_code, 403)
def test_it_checks_limits(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.save()
self.bobs_profile.check_limit = 0
self.bobs_profile.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.post(self.url, {"accept_transfer": "1"})
self.assertEqual(r.status_code, 400)
def test_reject_works(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
r = self.client.post(self.url, {"reject_transfer": "1"})
self.assertEqual(r.status_code, 200)
self.project.refresh_from_db()
# Alice should still be the owner
self.assertEqual(self.project.owner, self.alice)
# The transfer_request_date should be cleared out
self.bobs_membership.refresh_from_db()
self.assertIsNone(self.bobs_membership.transfer_request_date)
def test_only_the_proposed_owner_can_reject(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.save()
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, {"reject_transfer": "1"})
self.assertEqual(r.status_code, 403)
def test_readonly_user_can_accept(self):
self.bobs_membership.transfer_request_date = now()
self.bobs_membership.role = "r"
self.bobs_membership.save()
self.client.login(username="bob@example.org", password="password")
self.client.post(self.url, {"accept_transfer": "1"})
self.project.refresh_from_db()
# Bob should now be the owner
self.assertEqual(self.project.owner, self.bob)
# Alice, the previous owner, should now be a *regular* member
m = Member.objects.get(user=self.alice, project=self.project)
self.assertEqual(m.role, "w")

View File

@ -1,4 +1,6 @@
from datetime import timedelta as td
import time
from unittest.mock import patch
from django.core import signing
from django.utils.timezone import now
@ -6,40 +8,55 @@ from hc.test import BaseTestCase
class UnsubscribeReportsTestCase(BaseTestCase):
def test_token_works(self):
def test_it_unsubscribes(self):
self.profile.next_report_date = now()
self.profile.nag_period = td(hours=1)
self.profile.next_nag_date = now()
self.profile.save()
token = signing.Signer().sign("foo")
url = "/accounts/unsubscribe_reports/alice/?token=%s" % token
r = self.client.get(url)
self.assertContains(r, "You have been unsubscribed")
sig = signing.TimestampSigner(salt="reports").sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.post(url)
self.assertContains(r, "Unsubscribed")
self.profile.refresh_from_db()
self.assertFalse(self.profile.reports_allowed)
self.assertEqual(self.profile.reports, "off")
self.assertIsNone(self.profile.next_report_date)
self.assertEqual(self.profile.nag_period.total_seconds(), 0)
self.assertIsNone(self.profile.next_nag_date)
def test_bad_token_gets_rejected(self):
url = "/accounts/unsubscribe_reports/alice/?token=invalid"
r = self.client.get(url)
self.assertContains(r, "Incorrect Link")
def test_signed_username_works(self):
sig = signing.TimestampSigner(salt="reports").sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.get(url)
self.assertContains(r, "You have been unsubscribed")
self.profile.refresh_from_db()
self.assertFalse(self.profile.reports_allowed)
def test_bad_signature_gets_rejected(self):
url = "/accounts/unsubscribe_reports/invalid/"
r = self.client.get(url)
self.assertContains(r, "Incorrect Link")
def test_it_serves_confirmation_form(self):
sig = signing.TimestampSigner(salt="reports").sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.get(url)
self.assertContains(r, "Please press the button below")
self.assertNotContains(r, "submit()")
def test_aged_signature_autosubmits(self):
with patch("django.core.signing.time") as mock_time:
mock_time.time.return_value = time.time() - 301
signer = signing.TimestampSigner(salt="reports")
sig = signer.sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.get(url)
self.assertContains(r, "Please press the button below")
self.assertContains(r, "submit()")
def test_it_handles_missing_user(self):
self.alice.delete()
sig = signing.TimestampSigner(salt="reports").sign("alice")
url = "/accounts/unsubscribe_reports/%s/" % sig
r = self.client.post(url)
self.assertContains(r, "Unsubscribed")

View File

@ -1,36 +1,36 @@
from django.conf.urls import url
from django.urls import path
from hc.accounts import views
urlpatterns = [
url(r'^login/$', views.login, name="hc-login"),
url(r'^logout/$', views.logout, name="hc-logout"),
url(r'^login_link_sent/$',
views.login_link_sent, name="hc-login-link-sent"),
url(r'^link_sent/$',
views.link_sent, name="hc-link-sent"),
url(r'^check_token/([\w-]+)/([\w-]+)/$',
views.check_token, name="hc-check-token"),
url(r'^profile/$', views.profile, name="hc-profile"),
url(r'^profile/notifications/$', views.notifications, name="hc-notifications"),
url(r'^profile/badges/$', views.badges, name="hc-badges"),
url(r'^close/$', views.close, name="hc-close"),
url(r'^unsubscribe_reports/([\w\:-]+)/$',
views.unsubscribe_reports, name="hc-unsubscribe-reports"),
url(r'^set_password/([\w-]+)/$',
views.set_password, name="hc-set-password"),
url(r'^change_email/done/$',
views.change_email_done, name="hc-change-email-done"),
url(r'^change_email/([\w-]+)/$',
views.change_email, name="hc-change-email"),
url(r'^switch_team/([\w-]+)/$',
views.switch_team, name="hc-switch-team"),
path("login/", views.login, name="hc-login"),
path("login/two_factor/", views.login_webauthn, name="hc-login-webauthn"),
path("login/two_factor/totp/", views.login_totp, name="hc-login-totp"),
path("logout/", views.logout, name="hc-logout"),
path("signup/", views.signup, name="hc-signup"),
path("login_link_sent/", views.login_link_sent, name="hc-login-link-sent"),
path(
"check_token/<slug:username>/<slug:token>/",
views.check_token,
name="hc-check-token",
),
path("profile/", views.profile, name="hc-profile"),
path("profile/appearance/", views.appearance, name="hc-appearance"),
path("profile/notifications/", views.notifications, name="hc-notifications"),
path("close/", views.close, name="hc-close"),
path(
"unsubscribe_reports/<str:signed_username>/",
views.unsubscribe_reports,
name="hc-unsubscribe-reports",
),
path("set_password/", views.set_password, name="hc-set-password"),
path("change_email/done/", views.change_email_done, name="hc-change-email-done"),
path("change_email/", views.change_email, name="hc-change-email"),
path("two_factor/webauthn/", views.add_webauthn, name="hc-add-webauthn"),
path("two_factor/totp/", views.add_totp, name="hc-add-totp"),
path("two_factor/totp/remove/", views.remove_totp, name="hc-remove-totp"),
path(
"two_factor/<uuid:code>/remove/",
views.remove_credential,
name="hc-remove-credential",
),
]

File diff suppressed because it is too large Load Diff

View File

@ -1,50 +1,61 @@
from django.contrib import admin
from django.core.paginator import Paginator
from django.db import connection
from django.db.models import F
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from hc.api.models import Channel, Check, Notification, Ping
from hc.api.models import Channel, Check, Flip, Notification, Ping
from hc.lib.date import format_duration
class OwnershipListFilter(admin.SimpleListFilter):
title = "Ownership"
parameter_name = 'ownership'
def lookups(self, request, model_admin):
return (
('assigned', "Assigned"),
)
def queryset(self, request, queryset):
if self.value() == 'assigned':
return queryset.filter(user__isnull=False)
return queryset
@admin.register(Check)
class ChecksAdmin(admin.ModelAdmin):
class Media:
css = {
'all': ('css/admin/checks.css',)
}
css = {"all": ("css/admin/checks.css",)}
search_fields = ["name", "code", "project__owner__email"]
raw_id_fields = ("project",)
list_display = (
"id",
"name_tags",
"project_",
"created",
"n_pings",
"timeout_schedule",
"status",
"last_start",
"last_ping",
)
list_filter = ("status", "kind", "last_ping", "last_start")
search_fields = ["name", "user__email", "code"]
list_display = ("id", "name_tags", "created", "code", "timeout_schedule",
"status", "email", "last_ping", "n_pings")
list_select_related = ("user", )
list_filter = ("status", OwnershipListFilter, "kind", "last_ping")
actions = ["send_alert"]
def email(self, obj):
return obj.user.email if obj.user else None
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(email=F("project__owner__email"))
qs = qs.annotate(project_name=F("project__name"))
return qs
@mark_safe
def project_(self, obj):
url = reverse("hc-checks", args=[obj.project.code])
name = escape(obj.project_name or "Default")
email = escape(obj.email)
return f'{email} &rsaquo; <a href="{url}"">{name}</a>'
@mark_safe
def name_tags(self, obj):
if not obj.tags:
return obj.name
url = reverse("hc-details", args=[obj.code])
name = escape(obj.name or "unnamed")
return "%s [%s]" % (obj.name, obj.tags)
s = f'<a href="{url}"">{name}</a>'
for tag in obj.tags_list():
s += " <span>%s</span>" % escape(tag)
return s
@admin.display(description="Schedule")
def timeout_schedule(self, obj):
if obj.kind == "simple":
return format_duration(obj.timeout)
@ -53,27 +64,21 @@ class ChecksAdmin(admin.ModelAdmin):
else:
return "Unknown"
timeout_schedule.short_description = "Schedule"
@admin.action(description="Send Alert")
def send_alert(self, request, qs):
for check in qs:
check.send_alert()
for channel in check.channel_set.all():
channel.notify(check)
self.message_user(request, "%d alert(s) sent" % qs.count())
send_alert.short_description = "Send Alert"
class SchemeListFilter(admin.SimpleListFilter):
title = "Scheme"
parameter_name = 'scheme'
parameter_name = "scheme"
def lookups(self, request, model_admin):
return (
('http', "HTTP"),
('https', "HTTPS"),
('email', "Email"),
)
return (("http", "HTTP"), ("https", "HTTPS"), ("email", "Email"))
def queryset(self, request, queryset):
if self.value():
@ -83,7 +88,7 @@ class SchemeListFilter(admin.SimpleListFilter):
class MethodListFilter(admin.SimpleListFilter):
title = "Method"
parameter_name = 'method'
parameter_name = "method"
methods = ["HEAD", "GET", "POST", "PUT", "DELETE"]
def lookups(self, request, model_admin):
@ -95,6 +100,20 @@ class MethodListFilter(admin.SimpleListFilter):
return queryset
class KindListFilter(admin.SimpleListFilter):
title = "Kind"
parameter_name = "kind"
kinds = ["start", "fail"]
def lookups(self, request, model_admin):
return zip(self.kinds, self.kinds)
def queryset(self, request, queryset):
if self.value():
queryset = queryset.filter(kind=self.value())
return queryset
# Adapted from: https://djangosnippets.org/snippets/2593/
class LargeTablePaginator(Paginator):
""" Overrides the count method to get an estimate instead of actual count
@ -104,8 +123,10 @@ class LargeTablePaginator(Paginator):
def _get_estimate(self):
try:
cursor = connection.cursor()
cursor.execute("SELECT reltuples FROM pg_class WHERE relname = %s",
[self.object_list.query.model._meta.db_table])
cursor.execute(
"SELECT reltuples FROM pg_class WHERE relname = %s",
[self.object_list.query.model._meta.db_table],
)
return int(cursor.fetchone()[0])
except:
return 0
@ -130,70 +151,84 @@ class LargeTablePaginator(Paginator):
# (i.e. is of type list).
self._count = len(self.object_list)
return self._count
count = property(_get_count)
@admin.register(Ping)
class PingsAdmin(admin.ModelAdmin):
search_fields = ("owner__name", "owner__code", "owner__user__email")
list_select_related = ("owner", "owner__user")
list_display = ("id", "created", "check_name", "email", "scheme", "method",
"ua")
list_filter = ("created", SchemeListFilter, MethodListFilter)
search_fields = ("owner__name", "owner__code")
readonly_fields = ("owner",)
list_select_related = ("owner",)
list_display = ("id", "created", "owner", "scheme", "method", "ua")
list_filter = ("created", SchemeListFilter, MethodListFilter, KindListFilter)
paginator = LargeTablePaginator
def check_name(self, obj):
return obj.owner.name if obj.owner.name else obj.owner.code
def email(self, obj):
return obj.owner.user.email if obj.owner.user else None
show_full_result_count = False
@admin.register(Channel)
class ChannelsAdmin(admin.ModelAdmin):
class Media:
css = {
'all': ('css/admin/channels.css',)
}
css = {"all": ("css/admin/channels.css",)}
search_fields = ["value", "user__email"]
list_select_related = ("user", )
list_display = ("id", "code", "email", "formatted_kind", "value",
"num_notifications")
list_filter = ("kind", )
raw_id_fields = ("user", "checks", )
def email(self, obj):
return obj.user.email if obj.user else None
search_fields = ["value", "project__owner__email"]
list_display = ("id", "transport", "name", "project_", "value", "ok")
list_filter = ("kind",)
raw_id_fields = ("project", "checks")
@mark_safe
def formatted_kind(self, obj):
def project_(self, obj):
url = reverse("hc-checks", args=[obj.project_code])
name = escape(obj.project_name or "Default")
email = escape(obj.email)
return f"{email} &rsaquo; <a href='{url}'>{name}</a>"
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.annotate(project_code=F("project__code"))
qs = qs.annotate(project_name=F("project__name"))
qs = qs.annotate(email=F("project__owner__email"))
return qs
@mark_safe
def transport(self, obj):
note = ""
if obj.kind == "email" and not obj.email_verified:
return "Email <i>(unconfirmed)</i>"
note = " (not verified)"
return obj.get_kind_display()
return f'<span class="ic-{ obj.kind }"></span> &nbsp; {obj.kind}{note}'
formatted_kind.short_description = "Kind"
def num_notifications(self, obj):
return Notification.objects.filter(channel=obj).count()
num_notifications.short_description = "# Notifications"
@admin.display(boolean=True)
def ok(self, obj):
return False if obj.last_error else True
@admin.register(Notification)
class NotificationsAdmin(admin.ModelAdmin):
search_fields = ["owner__name", "owner__code", "channel__value"]
search_fields = ["owner__name", "owner__code", "channel__value", "error"]
readonly_fields = ("owner",)
list_select_related = ("owner", "channel")
list_display = ("id", "created", "check_status", "check_name",
"channel_kind", "channel_value")
list_display = (
"id",
"created",
"check_status",
"owner",
"channel_kind",
"channel_value",
"error",
)
list_filter = ("created", "check_status", "channel__kind")
def check_name(self, obj):
return obj.owner.name_then_code()
raw_id_fields = ("channel",)
def channel_kind(self, obj):
return obj.channel.kind
def channel_value(self, obj):
return obj.channel.value
@admin.register(Flip)
class FlipsAdmin(admin.ModelAdmin):
list_display = ("id", "created", "processed", "owner", "old_status", "new_status")
raw_id_fields = ("owner",)

View File

@ -1,72 +1,117 @@
import json
import re
from functools import wraps
from django.contrib.auth.models import User
from django.http import (HttpResponseBadRequest, HttpResponseForbidden,
JsonResponse)
from django.db.models import Q
from django.http import HttpResponse, JsonResponse
from hc.accounts.models import Project
from hc.lib.jsonschema import ValidationError, validate
RE_UUID = re.compile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
def error(msg, status=400):
return JsonResponse({"error": msg}, status=status)
def uuid_or_400(f):
def authorize(f):
@wraps(f)
def wrapper(request, *args, **kwds):
if not RE_UUID.match(args[0]):
return HttpResponseBadRequest()
return f(request, *args, **kwds)
return wrapper
def make_error(msg):
return JsonResponse({"error": msg}, status=400)
def check_api_key(f):
@wraps(f)
def wrapper(request, *args, **kwds):
request.json = {}
if request.body:
try:
request.json = json.loads(request.body.decode("utf-8"))
except ValueError:
return make_error("could not parse request body")
if "HTTP_X_API_KEY" in request.META:
api_key = request.META["HTTP_X_API_KEY"]
else:
api_key = request.json.get("api_key", "")
api_key = str(request.json.get("api_key", ""))
if api_key == "":
return make_error("wrong api_key")
if len(api_key) != 32:
return error("missing api key", 401)
try:
request.user = User.objects.get(profile__api_key=api_key)
except User.DoesNotExist:
return HttpResponseForbidden()
request.project = Project.objects.get(api_key=api_key)
except Project.DoesNotExist:
return error("wrong api key", 401)
request.readonly = False
return f(request, *args, **kwds)
return wrapper
def validate_json(schema):
""" Validate request.json contents against `schema`.
def authorize_read(f):
@wraps(f)
def wrapper(request, *args, **kwds):
if "HTTP_X_API_KEY" in request.META:
api_key = request.META["HTTP_X_API_KEY"]
else:
api_key = str(request.json.get("api_key", ""))
Supports a tiny subset of JSON schema spec.
if len(api_key) != 32:
return error("missing api key", 401)
write_key_match = Q(api_key=api_key)
read_key_match = Q(api_key_readonly=api_key)
try:
request.project = Project.objects.get(write_key_match | read_key_match)
except Project.DoesNotExist:
return error("wrong api key", 401)
request.readonly = api_key == request.project.api_key_readonly
return f(request, *args, **kwds)
return wrapper
def validate_json(schema=None):
""" Parse request json and validate it against `schema`.
Put the parsed result in `request.json`.
If schema is None then only parse and don't validate.
Supports a limited subset of JSON schema spec.
"""
def decorator(f):
@wraps(f)
def wrapper(request, *args, **kwds):
try:
validate(request.json, schema)
except ValidationError as e:
return make_error("json validation error: %s" % e)
if request.body:
try:
request.json = json.loads(request.body.decode())
except ValueError:
return error("could not parse request body")
else:
request.json = {}
if schema:
try:
validate(request.json, schema)
except ValidationError as e:
return error("json validation error: %s" % e)
return f(request, *args, **kwds)
return wrapper
return decorator
def cors(*methods):
methods = set(methods)
methods.add("OPTIONS")
methods_str = ", ".join(methods)
def decorator(f):
@wraps(f)
def wrapper(request, *args, **kwds):
if request.method == "OPTIONS":
# Handle OPTIONS here
response = HttpResponse(status=204)
elif request.method in methods:
response = f(request, *args, **kwds)
else:
response = HttpResponse(status=405)
response["Access-Control-Allow-Origin"] = "*"
response["Access-Control-Allow-Headers"] = "X-Api-Key"
response["Access-Control-Allow-Methods"] = methods_str
response["Access-Control-Max-Age"] = "600"
return response
return wrapper
return decorator

Some files were not shown because too many files have changed in this diff Show More