Compare commits
No commits in common. "master" and "v1.3.0" have entirely different histories.
1138 changed files with 17842 additions and 67679 deletions
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
|
@ -1 +0,0 @@
|
|||
github: [healthchecks]
|
33
.github/workflows/coverage.yml
vendored
33
.github/workflows/coverage.yml
vendored
|
@ -1,33 +0,0 @@
|
|||
name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libcurl4-openssl-dev libpython3-dev libssl-dev
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install apprise braintree coverage coveralls minio
|
||||
- name: Run Tests
|
||||
env:
|
||||
SECRET_KEY: dummy-key
|
||||
run: coverage run --omit=*/tests/*,*/migrations/* --source=hc manage.py test
|
||||
- name: Coveralls
|
||||
run: coveralls --service=github
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
28
.github/workflows/mypy.yml
vendored
28
.github/workflows/mypy.yml
vendored
|
@ -1,28 +0,0 @@
|
|||
name: Mypy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libcurl4-openssl-dev libpython3-dev libssl-dev
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install apprise braintree minio
|
||||
pip install mypy==1.13.0 django-stubs types-braintree types-pycurl==7.45.2.20240311 types-Markdown types-pygments
|
||||
touch hc/local_settings.py
|
||||
- name: Run Mypy
|
||||
run: mypy --strict hc
|
44
.github/workflows/publish_docker_image.yml
vendored
44
.github/workflows/publish_docker_image.yml
vendored
|
@ -1,44 +0,0 @@
|
|||
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@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: healthchecks/healthchecks
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
52
.github/workflows/tests.yml
vendored
52
.github/workflows/tests.yml
vendored
|
@ -1,52 +0,0 @@
|
|||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
db: [sqlite, postgres, mysql]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
include:
|
||||
- db: postgres
|
||||
db_user: runner
|
||||
db_password: ''
|
||||
- db: mysql
|
||||
db_user: root
|
||||
db_password: root
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
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: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libcurl4-openssl-dev libpython3-dev libssl-dev
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install apprise minio mysqlclient
|
||||
- name: Run Tests
|
||||
env:
|
||||
DB: ${{ matrix.db }}
|
||||
DB_USER: ${{ matrix.db_user }}
|
||||
DB_PASSWORD: ${{ matrix.db_password }}
|
||||
SECRET_KEY: dummy-key
|
||||
run: python manage.py test
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -2,8 +2,6 @@ __pycache__/
|
|||
*.pyc
|
||||
.coverage
|
||||
.env
|
||||
.venv
|
||||
.zed
|
||||
hc.sqlite
|
||||
hc/local_settings.py
|
||||
static-collected
|
||||
static-collected
|
17
.travis.yml
Normal file
17
.travis.yml
Normal file
|
@ -0,0 +1,17 @@
|
|||
language: python
|
||||
python:
|
||||
- "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
|
876
CHANGELOG.md
876
CHANGELOG.md
|
@ -1,875 +1,7 @@
|
|||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## v3.9 - 2024-12-20
|
||||
|
||||
### Improvements
|
||||
- Change the default value of ALLOWED_HOSTS from "*" to the domain part of SITE_ROOT
|
||||
|
||||
### Bug Fixes
|
||||
- Fix fetchstatus.py (again) to handle SITE_ROOT with a path (#1108)
|
||||
|
||||
## v3.8.2 - 2024-12-19
|
||||
|
||||
### Improvements
|
||||
- Update notification templates to include failure reason (#1069)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix fetchstatus.py to handle SITE_ROOT with a path (#1107)
|
||||
|
||||
## v3.8.1 - 2024-12-13
|
||||
|
||||
### Improvements
|
||||
- Update Dockerfile to use Python 3.13.1
|
||||
- Improve Matrix notifications (include tags, period, last ping type etc.)
|
||||
|
||||
## v3.8 - 2024-12-09
|
||||
|
||||
### Improvements
|
||||
- Rewrite the docker/fetchstatus.py script to reduce Docker container CPU use (#1071)
|
||||
- Update Dockerfile to use Python 3.13
|
||||
- Update CustomHeaderMiddleware to normalize email addresses to lower case (#1074)
|
||||
- Add data migration to convert existing user account emails to lower case
|
||||
- Update email alerts to mention failure reason (#1069)
|
||||
- De-emphasize the unsubscribe link in email notifications
|
||||
- In the checks list, move the "Add Check" button to the top of the page
|
||||
- Implement filtering by status in the checks list page
|
||||
- Increase ntfy.sh topic max length to 64
|
||||
- Implement support for path in SITE_ROOT, e.g. SITE_ROOT=http://example.org/hc (#1091)
|
||||
|
||||
### Bug Fixes
|
||||
- Improve recipient address validation in the smtp listener (#1077)
|
||||
|
||||
## v3.7 - 2024-10-21
|
||||
|
||||
### Improvements
|
||||
- Increase outgoing webhook timeout from 10 to 30 seconds
|
||||
- Remove `pruneflips` management command (now cleaned up automatically)
|
||||
- Remove `prunenotifications` management command (now cleaned up automatically)
|
||||
- Update settings.py to read SECURE_PROXY_SSL_HEADER from env vars
|
||||
- Remove LINE Notify onboarding form (as LINE Notify is shutting down on Apr 1, 2025)
|
||||
- Make slider labels clickable in the "Update Period & Grace" dialog (#1039)
|
||||
- Update the Signal integration to retry on network errors
|
||||
|
||||
### Bug Fixes
|
||||
- Update sqlite settings to avoid "Database is locked" errors (#1057)
|
||||
- Fix API to gracefully handle too long slugs
|
||||
|
||||
## v3.6 - 2024-09-04
|
||||
|
||||
### Security
|
||||
- Upgrade to Django 5.1.1 (it fixes a vulnerability in `urlize` which we do use)
|
||||
|
||||
### Improvements
|
||||
- Implement concurrent sending and `--num-workers` argument in `manage.py sendalerts`
|
||||
- Upgrade from psycopg2 to psycopg 3.x
|
||||
|
||||
## v3.5.2 - 2024-08-21
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the Docker healthcheck script to supply correct Host header (#1051)
|
||||
|
||||
## v3.5.1 - 2024-08-20
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the Dockerfile for building arm/v7 docker image
|
||||
|
||||
## v3.5 - 2024-08-20
|
||||
|
||||
Important: this Healthchecks release is using Django 5.1, which has dropped support
|
||||
for PostgreSQL 12. Therefore, the PostgreSQL image in the sample `docker-compose.yml`
|
||||
file has been updated from `postgres:12` to `postgres:16`. PostgreSQL does not
|
||||
automatically upgrade its data files between major version upgrades, you will need
|
||||
to do this manually. Instructions:
|
||||
https://github.com/healthchecks/healthchecks/tree/master/docker#upgrading-database
|
||||
|
||||
### Improvements
|
||||
- Improve performance of loading ping body previews (#1023)
|
||||
- Implement MS Teams Workflows integration (#1024)
|
||||
- Add "uuid" field in API responses when read/write key is used (#1007)
|
||||
- Update timezone dropdowns to show frequently used timezones at the top
|
||||
- Update the "Set Password" page to reject very weak passwords
|
||||
- Implement search by slug in the checks list (#1048)
|
||||
- Add support for $SLUG placeholder in webhook payloads (#1049)
|
||||
- Update Dockerfile to use HEALTHCHECK instruction and report container health (#1045)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix Check.ping() to lock the check before updating (#1023)
|
||||
- Fix AJAX views to better handle user logging out
|
||||
|
||||
## v3.4 - 2024-06-20
|
||||
|
||||
### Improvements
|
||||
- Show status changes (flips) in check's log page (#447)
|
||||
- Implement dynamic favicon in the projects overview page (#971)
|
||||
- Add support for system theme (#978, @moraj-turing)
|
||||
- Improve Opsgenie notifications (include description, schedule, link etc.)
|
||||
- Update the Discord integration to disable channel on HTTP 404 responses
|
||||
- Update email notifications to include the timestamps of status flips
|
||||
- Update the Sign In page to hide "Email Link" option if SMTP is not configured (#922)
|
||||
- Update Slack integration to use channel name as the integration name (#1003)
|
||||
- Update Ping Details dialog to also show formatted datetimes (#975)
|
||||
- Add data migration to update legacy timezones to current canonical timezones
|
||||
|
||||
### Bug Fixes
|
||||
- Fix hc.front.views.docs_search to handle words "AND", "OR", "NOT" as queries
|
||||
- Fix integrations to not disclose check's code in incident data
|
||||
- Fix integrations to include oncalendar schedules in notifications
|
||||
- Fix a bug in the log page that caused log events to sometimes load twice
|
||||
|
||||
## v3.3 - 2024-04-03
|
||||
|
||||
### Improvements
|
||||
- Add support for $NAME_JSON and $BODY_JSON placeholders in webhook payloads
|
||||
- Update the WhatsApp integration to use Twilio Content Templates
|
||||
- Add auto-refresh functionality to the Log page (#957, @mickBoat00)
|
||||
- Redesign the "Status Badges" page
|
||||
- Add support for per-check status badges (#853)
|
||||
- Add "Last ping subject" field in email notifications
|
||||
- Change the signup flow to accept registered users (and sign them in instead)
|
||||
- Implement event type filtering in the Log page (#873)
|
||||
- Implement dynamic favicon in the "Checks" and "Details" pages (#971, @princekhunt)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix Gotify integration to handle Gotify server URLs with paths (#964)
|
||||
- Update notification templates to handle cases where check's last ping value is null
|
||||
- Make statsd metrics collection optional (to enable, set STATSD_HOST env var)
|
||||
|
||||
## v3.2 - 2024-02-09
|
||||
|
||||
### Improvements
|
||||
- Update Opsgenie instructions
|
||||
- Update Spike.sh instructions
|
||||
- Add system check to validate settings.SITE_ROOT (#895)
|
||||
- Add tooltips to tag buttons in the checks list screen (#911)
|
||||
- Improve Email - Keywoard Filtering docs (@mmomjian)
|
||||
- Split the grace time input field into value/unit input group (#945, @mickBoat00)
|
||||
- Add a system check to warn about MariaDB UUID migration (#929)
|
||||
|
||||
### Bug Fixes
|
||||
- Increase uWSGI buffer size to allow requests with large cookies (#925)
|
||||
- Fix crash when processing one-shot OnCalendar schedules
|
||||
- Fix the handling of ping bodies > 2.5MB (#931)
|
||||
- Fix crash when inviting team member but SMTP is not configured (@marlenekoh)
|
||||
|
||||
## v3.1 - 2023-12-13
|
||||
|
||||
### Improvements
|
||||
- Update logging configuration to write logs to database (to table `logs_record`)
|
||||
- Improve Pushover notifications (include tags, period, last ping type etc.)
|
||||
- Implement audo-submit in TOTP entry screen (#905)
|
||||
- Update the Splunk On-Call integration to disable channel on HTTP 404 responses
|
||||
- Update the Slack integration to disable channel when Slack returns 400 "invalid_token"
|
||||
- Update the Pushover integration to disable channel when Pushover reports invalid user
|
||||
- Update Twilio integrations to disable channel on "Invalid 'To' Phone Number"
|
||||
- Update the Signal integration to disable channel on UNREGISTERED_FAILURE
|
||||
- Upgrade to Django 5.0
|
||||
- Add support for systemd's OnCalendar schedules (#919)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix "Ping Details" dialog to handle email bodies not yet uploaded to object storage
|
||||
- Fix webauthn registration failure on Firefox with Bitwarden extension
|
||||
- Fix webauthn registration failure on Firefox < 119 with Ed25519 keys
|
||||
|
||||
## v3.0.1 - 2023-10-30
|
||||
|
||||
### Bug Fixes
|
||||
- Fix sending test notification to a group integration
|
||||
- Fix the Login form to not perform form validation in GET requests
|
||||
- Fix special character escaping in ntfy notifications
|
||||
- Fix "Edit ntfy integration" page to fill the existing token in the form
|
||||
- Fix "Delete Check" and "Update Check" API calls to handle concurrent deletes
|
||||
- Fix Signal transport to handle JSON-RPC messages with no ids
|
||||
- Fix DST handling in Check.get_grace_start()
|
||||
|
||||
## v3.0 - 2023-10-16
|
||||
|
||||
This release drops support of Python 3.9 and below. The minimum required Python
|
||||
version is 3.10.
|
||||
|
||||
### Improvements
|
||||
- Add Channel.last_notify_duration field, use it in "sendalerts" for prioritization
|
||||
- Update Telegram integration to treat "bot was blocked by the user" as permanent error
|
||||
- Add "Time Zone" field in notifications that use the "Schedule" field (#863)
|
||||
- Add bold and monospace text formatting in Signal notifications
|
||||
- Update hourly/daily email reminders to only show checks in the "down" state (#881)
|
||||
- Add support for ntfy access tokens (#879)
|
||||
- Improve ntfy notifications (include tags, period, last ping type etc.)
|
||||
- Add an "Account closed." confirmation message after closing an account
|
||||
- Add monthly uptime percentage display in Check Details page (#773)
|
||||
- Increase the precision of calculated downtime duration in check's details and reports
|
||||
- Increase bottom margin for modal windows to work around Mobile Safari issue (#899)
|
||||
- New integration: notification group (#894)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix "senddeletionnotices" to recognize "Supporter" subscriptions
|
||||
- Fix "createsuperuser" to reject already registered email addresses (#880)
|
||||
- Fix hc.accounts.views.check_token to handle non-UUID usernames (#882)
|
||||
- Fix time interval formatting in Check Details page, downtime summary table
|
||||
- Fix HTML escaping issue in Project admin
|
||||
|
||||
## v2.10 - 2023-07-02
|
||||
|
||||
### Improvements
|
||||
- Configure logging to log unhandled exceptions to console even when DEBUG=False (#835)
|
||||
- Make hc.lib.emails raise exceptions when EMAIL_ settings are not set
|
||||
- Decouple check's name from slug, allow users to set hand-picked slugs
|
||||
- Add /api/v3/ (adds ability to specify slug when creating or updating checks)
|
||||
- Update Dockerfile to use Debian Bookworm as the base
|
||||
- Implement optional check auto-provisioning when pinging by slug (#626)
|
||||
- Add support for the $EXITSTATUS placeholder in webhook payloads (#826)
|
||||
- Add API support for filtering checks by slug (#844)
|
||||
- Add support for Telegram topics (#852)
|
||||
- For cron checks, switch to using check's (not browser's) timezone to format dates
|
||||
- Upgrade to cronsim 2.5 (adds support for "LW" in the day-of-month field)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix DB connection timeouts in `manage.py smtpd` (#847)
|
||||
|
||||
## v2.9.2 - 2023-06-05
|
||||
|
||||
### Bug Fixes
|
||||
- Fix a crash in `manage.py smtpd` when stdin is not attached (#840)
|
||||
|
||||
## v2.9.1 - 2023-06-05
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the GHA workflow for building arm/v7 docker image
|
||||
|
||||
## v2.9 - 2023-06-05
|
||||
|
||||
### Improvements
|
||||
- Switch from CssAbsoluteFilter to CssRelativeFilter (#822)
|
||||
- Add statsd metric collection in hc.lib.s3.get_object()
|
||||
- Upgrade to cronsim 2.4
|
||||
- Update Signal notification template to include more data
|
||||
- Add Profile.deletion_scheduled_deleted field, and UI banner when it's set
|
||||
- Add support for specifying MessagingServiceSid when sending SMS and WA messages
|
||||
- Update the smtpd management command to use the aiosmtpd library
|
||||
- Add Rocket.Chat integration (#463)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix a race condition when pinging and deleting checks at the same time
|
||||
- Fix the checks list to preserve filters when changing sort order (#828)
|
||||
|
||||
## v2.8.1 - 2023-04-11
|
||||
|
||||
### Bug Fixes
|
||||
- Fix django-compressor warning with github_actions.html
|
||||
|
||||
## v2.8 - 2023-04-11
|
||||
|
||||
### Improvements
|
||||
- Add GitHub Actions examples
|
||||
- Update the Dockerfile to use Python 3.11
|
||||
- Update the Ping Details dialog to show the "HTML" tab by default (#801)
|
||||
- Add a "Switch Project" menu in top navigation
|
||||
- Update Trello onboarding form to allow longer Trello auth tokens (#806)
|
||||
- Remove L10N markup from base.html, and associated translations
|
||||
- Add Arduino usage example
|
||||
- Upgrade to Django 4.2
|
||||
- Add email fallback for Signal notifications that hit rate limit
|
||||
- Make warnings about no backup second factor more assertive
|
||||
- Add cron expression tester and sample expressions in the cron cheatsheet page
|
||||
|
||||
### Bug Fixes
|
||||
- Fix notification query in the Log page
|
||||
|
||||
## v2.7 - 2023-03-06
|
||||
|
||||
### Improvements
|
||||
- Add last ping body in Mattermost notifications (#785)
|
||||
- Improve the error message about rejected private IPs
|
||||
- Update Docker image's uwsgi.ini to use SMTPD_PORT env var (#791)
|
||||
- Update Telegram notification template to include more data
|
||||
- Add CSRF protection in the signup view
|
||||
|
||||
### Bug Fixes
|
||||
- Fix URL validation to allow hostnames with no TLD ("http://example") (#782)
|
||||
- Add handling for ProtocolError exceptions in hc.lib.s3.get_object
|
||||
- Fix a race condition in Check.ping method
|
||||
- Fix the SameSite and Secure attributes on the "auto-login" cookie
|
||||
- Fix the "Test" button in the Integrations screen for read-only users
|
||||
- Add form double submit protection when registering a WebAuthn key
|
||||
|
||||
## v2.6.1 - 2023-01-26
|
||||
|
||||
### Improvements
|
||||
- Improve Prometheus docs, add section "Available Metrics"
|
||||
|
||||
### Bug Fixes
|
||||
- Fix a crash in the "createsuperuser" management command (#779)
|
||||
|
||||
## v2.6 - 2023-01-23
|
||||
|
||||
### Improvements
|
||||
- Improve layout in "My Checks" for checks with long ping URLs (#745)
|
||||
- Add support for communicating with signal-cli over TCP (#732)
|
||||
- Add /api/v2/ (changes the status reporting of checks in started state) (#633)
|
||||
- Update settings.py to read the ADMINS setting from an environment variable
|
||||
- Add "Start Keyword" filtering for inbound emails (#716)
|
||||
- Add rate limiting by client IP in the signup and login views
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the Signal integration to handle unexpected RPC messages better (#763)
|
||||
- Fix special character encoding in Signal notifications (#767)
|
||||
- Fix project sort order to be case-insensitive everywhere in the UI (#768)
|
||||
- Fix special character encoding in project invite emails
|
||||
- Fix check transfer between same account's projects when at check limit
|
||||
- Fix wording in the invite email when inviting read-only users
|
||||
- Fix login and signup views to make email enumeration harder
|
||||
|
||||
## v2.5 - 2022-12-14
|
||||
|
||||
### Improvements
|
||||
- Upgrade to fido2 1.1.0 and simplify hc.lib.webauthn
|
||||
- Add handling for ipv4address:port values in the X-Forwarded-For header (#714)
|
||||
- Add a form for submitting Signal CAPTCHA solutions
|
||||
- Add Duration field in the Ping Details dialog (#720)
|
||||
- Update Mattermost setup instructions
|
||||
- Add support for specifying a run ID via a "rid" query parameter (#722)
|
||||
- Add last ping body in Slack notifications (#735)
|
||||
- Add ntfy integration (#728)
|
||||
- Add ".txt" suffix to the filename when downloading ping body (#738)
|
||||
- Add API support for fetching ping bodies (#737)
|
||||
- Change "Settings - Email Reports" page to allow manual timezone selection
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the most recent ping lookup in the "Ping Details" dialog
|
||||
- Fix binary data handling in the hc.front.views.ping_body view
|
||||
- Fix downtime summaries in weekly reports (#736)
|
||||
- Fix week, month boundary calculation to use user's timezone
|
||||
|
||||
## v2.4.1 - 2022-10-18
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the GHA workflow for building arm/v7 docker image
|
||||
|
||||
## v2.4 - 2022-10-18
|
||||
|
||||
### Improvements
|
||||
- Add support for EMAIL_USE_SSL environment variable (#685)
|
||||
- Switch from requests to pycurl
|
||||
- Implement documentation search
|
||||
- Add date filters in the Log page
|
||||
- Upgrade to cronsim 2.3
|
||||
- Add support for the $BODY placeholder in webhook payloads (#708)
|
||||
- Implement the "Clear Events" function
|
||||
- Add support for custom topics in Zulip notifications (#583)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the handling of TooManyRedirects exceptions
|
||||
- Fix MySQL 8 support in the Docker image (upgrade from buster to bullseye) (#717)
|
||||
|
||||
## v2.3 - 2022-08-05
|
||||
|
||||
### Improvements
|
||||
- Update Dockerfile to start SMTP listener (#668)
|
||||
- Implement the "Add Check" dialog
|
||||
- Include last ping type in Slack, Mattermost, Discord notifications
|
||||
- Upgrade to cron-descriptor 1.2.30
|
||||
- Add "Filter by keywords in the message body" feature (#653)
|
||||
- Upgrade to HiDPI screenshots in the documentation
|
||||
- Add support for the $JSON placeholder in webhook payloads
|
||||
- Add ping endpoints for "log" events
|
||||
- Add the "Badges" page in docs
|
||||
- Add support for multiple recipients in incoming email (#669)
|
||||
- Upgrade to fido2 1.0.0, requests 2.28.1, segno 1.5.2
|
||||
- Implement auto-refresh and running indicator in the My Projects page (#681)
|
||||
- Upgrade to Django 4.1 and django-compressor 4.1
|
||||
- Add API support for resuming paused checks (#687)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the display of ignored pings with non-zero exit status
|
||||
- Fix a race condition in the "Change Email" flow
|
||||
- Fix grouping and sorting in the text version of the report/nag emails (#679)
|
||||
- Fix the update_timeout and pause views to create flips (for downtime bookkeeping)
|
||||
- Fix the checks list to preserve selected filters when adding/updating checks (#684)
|
||||
- Fix duration calculation to skip "log" and "ign" events
|
||||
|
||||
## v2.2.1 - 2022-06-13
|
||||
|
||||
### Improvements
|
||||
- Improve the text version of the alert email template
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the version number displayed in the footer
|
||||
|
||||
## v2.2 - 2022-06-13
|
||||
|
||||
### Improvements
|
||||
- Add address verification step in the "Change Email" flow
|
||||
- Reduce logging output from sendalerts and sendreports management commands (#656)
|
||||
- Add Ctrl+C handler in sendalerts and sendreports management commands
|
||||
- Add notes in docs about configuring uWSGI via UWSGI_ env vars (#656)
|
||||
- Implement login link expiration (login links will now expire in 1 hour)
|
||||
- Add Gotify integration (#270)
|
||||
- Add API support for reading/writing the subject and subject_fail fields (#659)
|
||||
- Add "Disabled" priority for Pushover notifications (#663)
|
||||
|
||||
### Bug Fixes
|
||||
- Update hc.front.views.channels to handle empty strings in settings (#635)
|
||||
- Add logic to handle ContentDecodingError exceptions
|
||||
|
||||
## v2.1 - 2022-05-10
|
||||
|
||||
### Improvements
|
||||
- Add logic to alert ADMINS when Signal transport hits a CAPTCHA challenge
|
||||
- Implement the "started" progress spinner in the details pages
|
||||
- Add "hc_check_started" metric in the Prometheus metrics endpoint (#630)
|
||||
- Add a management command for submitting Signal rate limit challenges
|
||||
- Upgrade to django-compressor 4.0
|
||||
- Update the C# snippet
|
||||
- Increase max displayed duration from 24h to 72h (#644)
|
||||
- Add "Ping-Body-Limit" response header in ping API responses
|
||||
|
||||
### Bug Fixes
|
||||
- Fix unwanted localization in badge SVG generation (#629)
|
||||
- Update email template to handle not yet uploaded ping bodies
|
||||
- Add small delay in transports.Email.notify to allow ping body to upload
|
||||
- Fix prunenotifications to handle checks with missing pings (#636)
|
||||
- Fix "Send Test Notification" for integrations that only send "up" notifications
|
||||
|
||||
## v2.0.1 - 2022-03-18
|
||||
|
||||
### Bug Fixes
|
||||
- Fix the GHA workflow for building arm/v7 docker image
|
||||
|
||||
## v2.0 - 2022-03-18
|
||||
|
||||
This release contains a backwards-incompatible change to the Signal integration
|
||||
(hence the major version number bump). Healthchecks uses signal-cli to deliver
|
||||
Signal notifications. In the past versions, Healthchecks interfaced with
|
||||
signal-cli over DBus. Starting from this version, Healthchecks interfaces
|
||||
with signal-cli using JSON RPC. Please see README for details on how to set
|
||||
this up.
|
||||
|
||||
### Improvements
|
||||
- Update Telegram integration to treat "group chat was deleted" as permanent error
|
||||
- Update email bounce handler to mark email channels as disabled (#446)
|
||||
- Update Signal integration to use JSON RPC over UNIX socket
|
||||
- Update the "Add TOTP" form to display plaintext TOTP secret (#602)
|
||||
- Improve PagerDuty notifications
|
||||
- Add Ping.body_raw field for storing body as bytes
|
||||
- Add support for storing ping bodies in S3-compatible object storage (#609)
|
||||
- Add a "Download Original" link in the "Ping Details" dialog
|
||||
|
||||
### Bug Fixes
|
||||
- Fix unwanted special character escaping in notification messages (#606)
|
||||
- Fix JS error after copying a code snippet
|
||||
- Make email non-editable in the "Invite Member" dialog when team limit reached
|
||||
- Fix Telegram bot to handle TransportError exceptions
|
||||
- Fix Signal integration to handle UNREGISTERED_FAILURE errors
|
||||
- Fix unwanted localization of period and grace values in data- attributes (#617)
|
||||
- Fix Mattermost integration to treat 404 as a transient error (#613)
|
||||
|
||||
## v1.25.0 - 2022-01-07
|
||||
|
||||
### Improvements
|
||||
- Implement Pushover emergency alert cancellation when check goes up
|
||||
- Add "The following checks are also down" section in Telegram notifications
|
||||
- Add "The following checks are also down" section in Signal notifications
|
||||
- Upgrade to django-compressor 3.0
|
||||
- Add support for Telegram channels (#592)
|
||||
- Implement Telegram group to supergroup migration (#132)
|
||||
- Update the Slack integration to not retry when Slack returns 404
|
||||
- Refactor transport classes to raise exceptions on delivery problems
|
||||
- Add Channel.disabled field, for disabling integrations on permanent errors
|
||||
- Upgrade to Django 4
|
||||
- Bump the min. Python version from 3.6 to 3.8 (as required by Django 4)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix report templates to not show the "started" status (show UP or DOWN instead)
|
||||
- Update Dockerfile to avoid running "pip wheel" more than once (#594)
|
||||
|
||||
## v1.24.1 - 2021-11-10
|
||||
|
||||
### Bug Fixes
|
||||
- Fix Dockerfile for arm/v7 - install all dependencies from piwheels
|
||||
|
||||
## v1.24.0 - 2021-11-10
|
||||
|
||||
### Improvements
|
||||
- Switch from croniter to cronsim
|
||||
- Change outgoing webhook timeout to 10s, but cap the total time to 20s
|
||||
- Implement automatic `api_ping` and `api_notification` pruning (#556)
|
||||
- Update Dockerfile to install apprise (#581)
|
||||
- Improve period and grace controls, allow up to 365 day periods (#281)
|
||||
- Add SIGTERM handling in sendalerts and sendreports
|
||||
- Remove the "welcome" landing page, direct users to the sign in form instead
|
||||
|
||||
### Bug Fixes
|
||||
- Fix hc.api.views.ping to handle non-utf8 data in request body (#574)
|
||||
- Fix a crash when hc.api.views.pause receives a single integer in request body
|
||||
|
||||
## v1.23.1 - 2021-10-13
|
||||
|
||||
### Bug Fixes
|
||||
- Fix missing uwsgi dependencies in arm/v7 Docker image
|
||||
|
||||
## v1.23.0 - 2021-10-13
|
||||
|
||||
### Improvements
|
||||
- Add /api/v1/badges/ endpoint (#552)
|
||||
- Add ability to edit existing email, Signal, SMS, WhatsApp integrations
|
||||
- Add new ping URL format: /{ping_key}/{slug} (#491)
|
||||
- Reduce Docker image size by using slim base image and multi-stage Dockerfile
|
||||
- Upgrade to Bootstrap 3.4.1
|
||||
- Upgrade to jQuery 3.6.0
|
||||
|
||||
### Bug Fixes
|
||||
- Add handling for non-latin-1 characters in webhook headers
|
||||
- Fix dark mode bug in selectpicker widgets
|
||||
- Fix a crash during login when user's profile does not exist (#77)
|
||||
- Drop API support for GET, DELETE requests with a request body
|
||||
- Add missing @csrf_exempt annotations in API views
|
||||
- Fix the ping handler to reject status codes > 255
|
||||
- Add 'schemaVersion' field in the shields.io endpoint (#566)
|
||||
|
||||
## v1.22.0 - 2021-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 - 2021-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 - 2021-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 characters
|
||||
- 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
|
||||
## 1.3.0 - 2018-11-21
|
||||
|
||||
### Improvements
|
||||
- Load settings from environment variables
|
||||
|
@ -889,7 +21,7 @@ this up.
|
|||
- During DST transition, handle ambiguous dates as pre-transition
|
||||
|
||||
|
||||
## v1.2.0 - 2018-10-20
|
||||
## 1.2.0 - 2018-10-20
|
||||
|
||||
### Improvements
|
||||
- Content updates in the "Welcome" page.
|
||||
|
@ -904,10 +36,10 @@ this up.
|
|||
- Fix hamburger menu button in "Login" page.
|
||||
|
||||
|
||||
## v1.1.0 - 2018-08-20
|
||||
## 1.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.
|
||||
- Checks have a "Description" field.
|
|
@ -1,60 +0,0 @@
|
|||
# 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/docs_single.html`
|
||||
|
||||
## Developing a New Integration
|
||||
|
||||
Before starting work on a new integration, please open an issue and
|
||||
discuss it first. We use several criteria when deciding whether to work on an
|
||||
integration or accept a PR:
|
||||
|
||||
* Most important: is there substantial end-user (ideally, paying or would-be-paying
|
||||
end user) interest, across GitHub issues, private emails, social media?
|
||||
* Would it be fun to work on?
|
||||
* Is the service we are integrating with developer-friendly? Does it have an open
|
||||
and well-documented API? Can we develop and test the integration while avoiding
|
||||
sales calls, contract signing, paid subscriptions?
|
||||
* Does the new integration enable something that is otherwise not possible (or is
|
||||
very inconvenient) via webhooks or email?
|
||||
|
||||
The best way to build a new integration is to pick a similar existing integration
|
||||
as a starting point for the new integration and replicate every aspect of it.
|
||||
You will need to make changes in the following files:
|
||||
|
||||
* Add a new class in `/hc/api/transports.py`.
|
||||
* Add a new notification template in `/templates/integrations/`.
|
||||
* Write testcases for the new transport class in `/hc/api/tests/test_notify_<kind>.py`.
|
||||
* Update `CHANNEL_KINDS` in `/hc/api/models.py`.
|
||||
* Update `Channel.transport()` in `/hc/api/models.py`.
|
||||
* Create a view for provisioning the new integration in `/hc/front/views.py`.
|
||||
* Write a HTML template for the new view in `/templates/front/add_<kind>.py`, and
|
||||
prepare any supporting illustrations in `/static/img/integrations/`.
|
||||
* Add a route for the new view in `/hc/front/urls.py`.
|
||||
* Write testcases for the new view in `/hc/font/tests/test_add_<kind>.py`.
|
||||
* Update `/templates/front/channels.html` – add a new section in the list of available
|
||||
integrations, make sure an existing integration is displayed nicely.
|
||||
* Update `/templates/front/event_summary.html` to make sure notifications sent to the
|
||||
new integration are displayed nicely.
|
||||
* Add a logo in `/static/img/integrations/`.
|
||||
* Update the icon font (it's a little tricky to do, I can take care of that).
|
||||
|
2
LICENSE
2
LICENSE
|
@ -1,4 +1,4 @@
|
|||
Copyright (c) 2015, Pēteris Caune and other contributors
|
||||
Copyright (c) 2015, Pēteris Caune
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
|
665
README.md
665
README.md
|
@ -1,414 +1,278 @@
|
|||
# Healthchecks
|
||||
# healthchecks
|
||||
|
||||
[](https://github.com/healthchecks/healthchecks/actions/workflows/tests.yml)
|
||||
[](https://travis-ci.org/healthchecks/healthchecks)
|
||||
[](https://coveralls.io/github/healthchecks/healthchecks?branch=master)
|
||||
|
||||
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.
|
||||
|
||||
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 3.10+
|
||||
* Django 5.1
|
||||
* 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/).
|
||||
|
||||
A [Dockerfile](https://github.com/healthchecks/healthchecks/tree/master/docker)
|
||||
and [pre-built Docker images](https://hub.docker.com/r/healthchecks/healthchecks) are
|
||||
available.
|
||||
|
||||
Screenshots:
|
||||
|
||||
The "My Checks" screen. Shows the status of all your cron jobs
|
||||
in a live-updating dashboard.
|
||||

|
||||
|
||||

|
||||
|
||||
Each check has configurable Period and Grace Time parameters. Period is the expected
|
||||
time between pings. Grace Time specifies how long to wait before sending out alerts
|
||||
when a job is running late.
|
||||
|
||||

|
||||
|
||||
Alternatively, you can define the expected schedules using a cron expressions.
|
||||
Healthchecks uses the [cronsim](https://github.com/cuu508/cronsim) library to
|
||||
parse and evaluate cron expressions.
|
||||
|
||||

|
||||
|
||||
Check details page, with a live-updating event log.
|
||||

|
||||
|
||||

|
||||
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 provides status badges with public but hard-to-guess URLs.
|
||||
You can use them in your READMEs, dashboards, or status pages.
|
||||
It is live here: [http://healthchecks.io/](http://healthchecks.io/)
|
||||
|
||||

|
||||
The building blocks are:
|
||||
|
||||
* Python 3
|
||||
* Django 2
|
||||
* PostgreSQL or MySQL
|
||||
|
||||
## Setting Up for Development
|
||||
|
||||
To set up Healthchecks development environment:
|
||||
These are instructions for setting up healthchecks Django app
|
||||
in development environment.
|
||||
|
||||
* Install dependencies (Debian/Ubuntu):
|
||||
* install dependencies (Debian/Ubuntu)
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
sudo apt install -y gcc python3-dev python3-venv libpq-dev libcurl4-openssl-dev libssl-dev
|
||||
```
|
||||
$ sudo apt-get update
|
||||
$ sudo apt-get install -y gcc python3-dev
|
||||
|
||||
* Prepare directory for project code and virtualenv. Feel free to use a
|
||||
different location:
|
||||
* prepare directory for project code and virtualenv:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/webapps
|
||||
cd ~/webapps
|
||||
```
|
||||
$ mkdir -p ~/webapps
|
||||
$ cd ~/webapps
|
||||
|
||||
* Prepare virtual environment
|
||||
* prepare virtual environment
|
||||
(with virtualenv you get pip, we'll use it soon to install requirements):
|
||||
|
||||
```sh
|
||||
python3 -m venv hc-venv
|
||||
source hc-venv/bin/activate
|
||||
pip3 install wheel # make sure wheel is installed in the venv
|
||||
```
|
||||
$ python3 -m venv hc-venv
|
||||
$ source hc-venv/bin/activate
|
||||
|
||||
* Check out project code:
|
||||
* check out project code:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/healthchecks/healthchecks.git
|
||||
```
|
||||
$ git clone https://github.com/healthchecks/healthchecks.git
|
||||
|
||||
* Install requirements (Django, ...) into virtualenv:
|
||||
* install requirements (Django, ...) into virtualenv:
|
||||
|
||||
```sh
|
||||
pip install -r healthchecks/requirements.txt
|
||||
```
|
||||
$ pip install -r healthchecks/requirements.txt
|
||||
|
||||
* macOS only - pycurl needs to be reinstalled using the following method (assumes OpenSSL was installed using brew):
|
||||
* 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:
|
||||
|
||||
```sh
|
||||
export PYCURL_VERSION=`cat requirements.txt | grep pycurl | cut -d '=' -f3`
|
||||
export OPENSSL_LOCATION=`brew --prefix openssl`
|
||||
export PYCURL_SSL_LIBRARY=openssl
|
||||
export LDFLAGS=-L$OPENSSL_LOCATION/lib
|
||||
export CPPFLAGS=-I$OPENSSL_LOCATION/include
|
||||
pip uninstall -y pycurl
|
||||
pip install pycurl==$PYCURL_VERSION --compile --no-cache-dir
|
||||
```
|
||||
$ cd ~/webapps/healthchecks
|
||||
$ cp hc/local_settings.py.example hc/local_settings.py
|
||||
|
||||
* Create database tables and a superuser account:
|
||||
* create database tables and the superuser account:
|
||||
|
||||
```sh
|
||||
cd ~/webapps/healthchecks
|
||||
./manage.py migrate
|
||||
./manage.py createsuperuser
|
||||
```
|
||||
$ cd ~/webapps/healthchecks
|
||||
$ ./manage.py migrate
|
||||
$ ./manage.py createsuperuser
|
||||
|
||||
With the default configuration, Healthchecks stores data in a SQLite file
|
||||
`hc.sqlite` in the checkout directory (`~/webapps/healthchecks`).
|
||||
* run development server:
|
||||
|
||||
* Run tests:
|
||||
$ ./manage.py runserver
|
||||
|
||||
```sh
|
||||
./manage.py test
|
||||
```
|
||||
|
||||
* Run development server:
|
||||
|
||||
```sh
|
||||
./manage.py runserver
|
||||
```
|
||||
|
||||
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/`
|
||||
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`
|
||||
|
||||
## Configuration
|
||||
|
||||
Healthchecks reads configuration from environment variables. See the
|
||||
[full list of configuration parameters](https://healthchecks.io/docs/self_hosted_configuration/)
|
||||
you can set via environment variables.
|
||||
Site configuration is loaded from environment variables. This is
|
||||
done 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, or add extra settings as needed.
|
||||
|
||||
In addition, Healthchecks reads settings from the `hc/local_settings.py` file if it
|
||||
exists. You can set or override any [standard Django setting](https://docs.djangoproject.com/en/5.1/ref/settings/)
|
||||
in this file. You can copy the provided `hc/local_settings.py.example` as
|
||||
`hc/local_settings.py` and use it as a starting point.
|
||||
Configurations settings loaded from environment variables:
|
||||
|
||||
If a setting is specified both as environment variable and in `hc/local_settings.py`,
|
||||
the latter takes precedence.
|
||||
| Environment variable | Default value | Notes
|
||||
| -------------------- | ------------- | ----- |
|
||||
| [SECRET_KEY](https://docs.djangoproject.com/en/2.1/ref/settings/#secret-key) | `"---"`
|
||||
| [DEBUG](https://docs.djangoproject.com/en/2.1/ref/settings/#debug) | `True` | Set to `False` for production
|
||||
| [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.1/ref/settings/#allowed-hosts) | `*` | Separate multiple hosts with commas
|
||||
| [DEFAULT_FROM_EMAIL](https://docs.djangoproject.com/en/2.1/ref/settings/#default-from-email) | `"healthchecks@example.org"`
|
||||
| USE_PAYMENTS | `False`
|
||||
| REGISTRATION_OPEN | `True`
|
||||
| DB | `"sqlite"` | Set to `"postgres"` or `"mysql"`
|
||||
| [DB_HOST](https://docs.djangoproject.com/en/2.1/ref/settings/#host) | `""` *(empty string)*
|
||||
| [DB_PORT](https://docs.djangoproject.com/en/2.1/ref/settings/#port) | `""` *(empty string)*
|
||||
| [DB_NAME](https://docs.djangoproject.com/en/2.1/ref/settings/#name) | `"hc"`
|
||||
| [DB_USER](https://docs.djangoproject.com/en/2.1/ref/settings/#user) | `"postgres"` or `"root"`
|
||||
| [DB_PASSWORD](https://docs.djangoproject.com/en/2.1/ref/settings/#password) | `""` *(empty string)*
|
||||
| [DB_CONN_MAX_AGE](https://docs.djangoproject.com/en/2.1/ref/settings/#conn-max-age) | `0`
|
||||
| DB_SSLMODE | `"prefer"` | PostgreSQL-specific, [details](https://blog.github.com/2018-10-21-october21-incident-report/)
|
||||
| DB_TARGET_SESSION_ATTRS | `"read-write"` | PostgreSQL-specific, [details](https://www.postgresql.org/docs/10/static/libpq-connect.html#LIBPQ-CONNECT-TARGET-SESSION-ATTRS)
|
||||
| SITE_ROOT | `"http://localhost:8000"`
|
||||
| SITE_NAME | `"Mychecks"`
|
||||
| MASTER_BADGE_LABEL | `"Mychecks"`
|
||||
| PING_ENDPOINT | `"http://localhost:8000/ping/"`
|
||||
| PING_EMAIL_DOMAIN | `"localhost"`
|
||||
| DISCORD_CLIENT_ID | `None`
|
||||
| DISCORD_CLIENT_SECRET | `None`
|
||||
| SLACK_CLIENT_ID | `None`
|
||||
| SLACK_CLIENT_SECRET | `None`
|
||||
| PUSHOVER_API_TOKEN | `None`
|
||||
| PUSHOVER_SUBSCRIPTION_URL | `None`
|
||||
| PUSHOVER_EMERGENCY_RETRY_DELAY | `300`
|
||||
| PUSHOVER_EMERGENCY_EXPIRATION | `86400`
|
||||
| PUSHBULLET_CLIENT_ID | `None`
|
||||
| PUSHBULLET_CLIENT_SECRET | `None`
|
||||
| TELEGRAM_BOT_NAME | `"ExampleBot"`
|
||||
| TELEGRAM_TOKEN | `None`
|
||||
| TWILIO_ACCOUNT | `None`
|
||||
| TWILIO_AUTH | `None`
|
||||
| TWILIO_FROM | `None`
|
||||
| PD_VENDOR_KEY | `None`
|
||||
| TRELLO_APP_KEY | `None`
|
||||
|
||||
## Accessing Administration Panel
|
||||
|
||||
Healthchecks comes with Django's administration panel where you can perform
|
||||
administrative tasks: delete user accounts, change passwords, increase limits for
|
||||
specific users, inspect contents of database tables.
|
||||
Some useful settings keys to override are:
|
||||
|
||||
To access the administration panel,
|
||||
`SITE_ROOT` is used to build fully qualified URLs for pings, and for use in
|
||||
emails and notifications. 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_ROOT = "https://my-monitoring-project.com"
|
||||
```
|
||||
|
||||
`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:
|
||||
|
||||
```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 loaded from environment variables. If you
|
||||
need to use a non-standard configuration, you can override the
|
||||
database configuration in `hc/local_settings.py` like so:
|
||||
|
||||
```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'},
|
||||
'OPTIONS': {
|
||||
... your custom options here ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sending Emails
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
- Implicit TLS (*recommended*):
|
||||
```python
|
||||
DEFAULT_FROM_EMAIL = "valid-sender-address@example.org"
|
||||
EMAIL_HOST = "your-smtp-server-here.com"
|
||||
EMAIL_PORT = 465
|
||||
EMAIL_HOST_USER = "smtp-username"
|
||||
EMAIL_HOST_PASSWORD = "smtp-password"
|
||||
EMAIL_USE_TLS = False
|
||||
EMAIL_USE_SSL = True
|
||||
```
|
||||
|
||||
Port 465 should be the preferred method according to [RFC8314 Section 3.3: Implicit TLS for SMTP Submission](https://tools.ietf.org/html/rfc8314#section-3.3). Be sure to use a TLS certificate and not an SSL one.
|
||||
|
||||
- Explicit TLS:
|
||||
```python
|
||||
DEFAULT_FROM_EMAIL = "valid-sender-address@example.org"
|
||||
EMAIL_HOST = "your-smtp-server-here.com"
|
||||
EMAIL_PORT = 587
|
||||
EMAIL_HOST_USER = "smtp-username"
|
||||
EMAIL_HOST_PASSWORD = "smtp-password"
|
||||
EMAIL_USE_TLS = True
|
||||
```
|
||||
```python
|
||||
EMAIL_HOST = "your-smtp-server-here.com"
|
||||
EMAIL_PORT = 587
|
||||
EMAIL_HOST_USER = "username"
|
||||
EMAIL_HOST_PASSWORD = "password"
|
||||
EMAIL_USE_TLS = True
|
||||
```
|
||||
|
||||
For more information, have a look at Django documentation,
|
||||
[Sending Email](https://docs.djangoproject.com/en/4.2/topics/email/) section.
|
||||
[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
|
||||
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:
|
||||
|
||||
```sh
|
||||
./manage.py smtpd --port 2525
|
||||
```
|
||||
$ ./manage.py smtpd --port 2525
|
||||
|
||||
Send a test email:
|
||||
|
||||
```sh
|
||||
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 '='
|
||||
```
|
||||
$ 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 Alerts and Reports
|
||||
|
||||
Healthchecks comes with a `sendalerts` management command, which continuously
|
||||
|
||||
## Sending Status Notifications
|
||||
|
||||
healtchecks comes with a `sendalerts` management command, which continuously
|
||||
polls database for any checks changing state, and sends out notifications as
|
||||
needed. Within an activated virtualenv, you can manually run
|
||||
the `sendalerts` command like so:
|
||||
|
||||
```sh
|
||||
./manage.py sendalerts
|
||||
```
|
||||
$ ./manage.py sendalerts
|
||||
|
||||
In a production setup, you will want to run this command from a process
|
||||
manager like systemd or [supervisor](http://supervisord.org/).
|
||||
|
||||
Healthchecks also comes with a `sendreports` management command which
|
||||
sends out monthly reports, weekly reports, and the daily or hourly reminders.
|
||||
|
||||
Run `sendreports` without arguments to run any due reports and reminders
|
||||
and then exit:
|
||||
|
||||
```sh
|
||||
./manage.py sendreports
|
||||
```
|
||||
|
||||
Run it with the `--loop` argument to make it run continuously:
|
||||
|
||||
```sh
|
||||
./manage.py sendreports --loop
|
||||
```
|
||||
manager like [supervisor](http://supervisord.org/) or systemd.
|
||||
|
||||
## Database Cleanup
|
||||
|
||||
Healthchecks deletes old entries from `api_ping`, `api_flip`, and `api_notification`
|
||||
tables automatically. By default, Healthchecks keeps the 100 most recent
|
||||
pings for every check. You can set the limit higher to keep a longer history:
|
||||
go to the Administration Panel, look up user's **Profile** and modify its
|
||||
"Ping log limit" field.
|
||||
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:
|
||||
|
||||
Healthchecks also provides management commands for cleaning up
|
||||
`auth_user` (user accounts) and `api_tokenbucket` (rate limiting records) tables,
|
||||
and for removing stale objects from external object storage.
|
||||
* Remove old records from `api_ping` table. For each check, keep 100 most
|
||||
recent pings:
|
||||
|
||||
* Remove user accounts that are older than 1 month and have never logged in:
|
||||
```
|
||||
$ ./manage.py prunepings
|
||||
```
|
||||
|
||||
```sh
|
||||
./manage.py pruneusers
|
||||
```
|
||||
* 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:
|
||||
|
||||
* 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 prunechecks
|
||||
```
|
||||
|
||||
```sh
|
||||
./manage.py prunetokenbucket
|
||||
```
|
||||
* Remove old records of sent notifications. For each check, remove
|
||||
notifications that are older than the oldest stored ping for same check.
|
||||
|
||||
* Remove old objects from external object storage. When an user removes
|
||||
a check, removes a project, or closes their account, Healthchecks
|
||||
does not remove the associated objects from the external object
|
||||
storage on the fly. Instead, you should run `pruneobjects` occasionally
|
||||
(for example, once a month). This command first takes an inventory
|
||||
of all checks in the database, and then iterates over top-level
|
||||
keys in the object storage bucket, and deletes any that don't also
|
||||
exist in the database.
|
||||
```
|
||||
$ ./manage.py prunenotifications
|
||||
```
|
||||
|
||||
```sh
|
||||
./manage.py pruneobjects
|
||||
```
|
||||
* Remove user accounts that match either of these conditions:
|
||||
* 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.
|
||||
Assume the user doesn't intend to use the account any more and would
|
||||
probably *want* it removed.
|
||||
|
||||
```
|
||||
$ ./manage.py pruneusers
|
||||
```
|
||||
|
||||
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, **healthchecks 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)
|
||||
|
||||
The header name in `REMOTE_USER_HEADER` must be specified in upper-case,
|
||||
with any dashes replaced with underscores, and prefixed with `HTTP_`. For
|
||||
example, if your authentication proxy sets a `X-Authenticated-User` request
|
||||
header, you should set `REMOTE_USER_HEADER=HTTP_X_AUTHENTICATED_USER`.
|
||||
|
||||
**Note on using `local_settings.py`:**
|
||||
When Healthchecks reads settings from environment variables and encounters
|
||||
the `REMOTE_USER_HEADER` environment variable, it sets *two* settings,
|
||||
`REMOTE_USER_HEADER` and `AUTHENTICATION_BACKENDS`. This logic has already run by the
|
||||
time Healthchecks reads `local_settings.py`. Therefore, if you configure Healthchecks
|
||||
using the `local_settings.py` file instead of environment variables, and specify
|
||||
`REMOTE_USER_HEADER` there, you will also need a line which sets the other setting,
|
||||
`AUTHENTICATION_BACKENDS`:
|
||||
|
||||
```
|
||||
REMOTE_USER_HEADER = "HTTP_X_AUTHENTICATED_USER"
|
||||
AUTHENTICATION_BACKENDS = ["hc.accounts.backends.CustomHeaderBackend"]
|
||||
```
|
||||
|
||||
## External Object Storage
|
||||
|
||||
Healthchecks can optionally store large ping bodies in S3-compatible object
|
||||
storage. To enable this feature, you will need to:
|
||||
|
||||
* ensure you have the [MinIO Python library](https://docs.min.io/docs/python-client-quickstart-guide.html) installed:
|
||||
|
||||
```bash
|
||||
pip install minio
|
||||
```
|
||||
* configure the credentials for accessing object storage: `S3_ACCESS_KEY`,
|
||||
`S3_SECRET_KEY`, `S3_ENDPOINT`, `S3_REGION` and `S3_BUCKET`.
|
||||
|
||||
Healthchecks will use external object storage for storing any request bodies that
|
||||
exceed 100 bytes. If the size of a request body is 100 bytes or below, Healthchecks
|
||||
will still store it in the database.
|
||||
|
||||
Healthchecks automatically removes old stored ping bodies from object
|
||||
storage while uploading new data. However, Healthchecks does not automatically
|
||||
clean up data when you delete checks, projects or entire user accounts.
|
||||
Use the `pruneobjects` management command to remove data for checks that don't
|
||||
exist any more.
|
||||
|
||||
When external object storage is not enabled (the credentials for accessing object
|
||||
storage are not set), Healthchecks stores all ping bodies in the database.
|
||||
If you enable external object storage, Healthchecks will still be able to
|
||||
access the ping bodies already stored in the database. You don't need to migrate
|
||||
them to the object storage. On the other hand, if you later decide to disable
|
||||
external object storage, Healthchecks will not have access to the externally
|
||||
stored ping bodies any more. And there is currently no script or management command
|
||||
for migrating ping bodies from external object storage back to the database.
|
||||
|
||||
## Integrations
|
||||
|
||||
### Slack
|
||||
|
||||
Healthchecks supports two Slack integration setup flows: legacy and app-based.
|
||||
|
||||
The legacy flow does not require additional configuration and is used by default.
|
||||
In this flow the user creates an incoming webhook URL on the Slack side, and
|
||||
pastes the webhook URL in a form on the Healthchecks side.
|
||||
|
||||
In the app-based flow the user clicks an "Add to Slack" button in Healthchecks,
|
||||
and gets transferred to a Slack-hosted dialog where they select the channel to
|
||||
post notifications to. This flow uses OAuth2 behind the scenes. To enable this
|
||||
flow, you will need to set up a Slack OAuth2 app:
|
||||
|
||||
* 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`).
|
||||
* 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. Put them
|
||||
in `SLACK_CLIENT_ID` and `SLACK_CLIENT_SECRET` environment
|
||||
variables. Once these variables are set, Healthchecks will switch from using
|
||||
the legacy flow to using the app-based flow.
|
||||
|
||||
The legacy and app-based flows only affect the user experience during the initial
|
||||
setup of Slack integrations. The contents of notifications posted to Slack are the same
|
||||
regardless of the setup flow used.
|
||||
|
||||
### Discord
|
||||
|
||||
To enable Discord integration, you will need to:
|
||||
|
||||
* register a new application on https://discord.com/developers/applications/me
|
||||
* register a new application on https://discordapp.com/developers/applications/me
|
||||
* add a redirect URI to your Discord application. The URI format is
|
||||
`SITE_ROOT/integrations/add_discord/`. For example, if you are running a
|
||||
development server on `localhost:8000` then the redirect URI would be
|
||||
|
@ -420,177 +284,30 @@ To enable Discord integration, you will need to:
|
|||
|
||||
### Pushover
|
||||
|
||||
Pushover integration works by creating an application on Pushover.net which
|
||||
is then subscribed to by Healthchecks users. The registration workflow is as follows:
|
||||
To enable Pushover integration, you will need to:
|
||||
|
||||
* 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
|
||||
* register a new application on https://pushover.net/apps/build
|
||||
* enable subscriptions in your application and make sure to enable the URL
|
||||
subscription type
|
||||
* put the application token and the subscription URL in
|
||||
`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 UNIX or TCP socket.
|
||||
Healthchecks requires signal-cli version 0.11.2 or later.
|
||||
|
||||
To enable the Signal integration via UNIX socket:
|
||||
|
||||
* Set up and configure signal-cli to expose JSON RPC on an UNIX socket
|
||||
([instructions](https://github.com/AsamK/signal-cli/wiki/JSON-RPC-service)).
|
||||
Example: `signal-cli -a +xxxxxx daemon --socket /tmp/signal-cli-socket`
|
||||
* Put the socket's location in the `SIGNAL_CLI_SOCKET` environment variable.
|
||||
|
||||
To enable the Signal integration via TCP socket:
|
||||
|
||||
* Set up and configure signal-cli to expose JSON RPC on a TCP socket.
|
||||
Example: `signal-cli -a +xxxxxx daemon --tcp 127.0.0.1:7583`
|
||||
* Put the socket's hostname and port in the `SIGNAL_CLI_SOCKET` environment variable
|
||||
using "hostname:port" syntax, example: `127.0.0.1:7583`.
|
||||
|
||||
variables
|
||||
|
||||
### 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. To avoid user confusion,
|
||||
please do not use the Healthchecks.io logo as your bot's user picture, use
|
||||
your own logo.
|
||||
description, user picture, and add a "/start" command.
|
||||
* 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:
|
||||
|
||||
```sh
|
||||
./manage.py settelegramwebhook
|
||||
```
|
||||
$ ./manage.py settelegramwebhook
|
||||
Done, Telegram's webhook set to: https://my-monitoring-project.com/integrations/telegram/bot/
|
||||
```
|
||||
|
||||
For this to work, your `SITE_ROOT` must be correct and must use the "https://"
|
||||
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/4.2/ref/settings/#debug). Make sure it is
|
||||
set to `False`.
|
||||
* [ALLOWED_HOSTS](https://docs.djangoproject.com/en/4.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/4.2/ref/settings/#admins) and
|
||||
[SERVER_EMAIL](https://docs.djangoproject.com/en/4.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/).
|
||||
An example of a minimal setup would be to install uWSGI using `pip3 install uwsgi`,
|
||||
and to run `uwsgi --http :8000 --module hc.wsgi` from the project's root directory.
|
||||
* `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).
|
||||
|
||||
## Docker Image
|
||||
|
||||
Healthchecks provides a reference Dockerfile and prebuilt Docker images for every
|
||||
release. The Dockerfile lives in the [/docker/](https://github.com/healthchecks/healthchecks/tree/master/docker)
|
||||
directory, and Docker images for amd64, arm/v7 and arm64 architectures are available
|
||||
[on Docker Hub](https://hub.docker.com/r/healthchecks/healthchecks).
|
||||
|
||||
The Docker images:
|
||||
|
||||
* Use uWSGI as the web server. uWSGI is configured to perform database migrations
|
||||
on startup, and to run `sendalerts`, `sendreports`, and `smtpd` in the background.
|
||||
You do not need to run them separately.
|
||||
* Ship with both PostgreSQL and MySQL database drivers.
|
||||
* Serve static files using the whitenoise library.
|
||||
* Have the apprise library preinstalled.
|
||||
* Do *not* handle TLS termination. In a production setup, you will want to put
|
||||
the Healthchecks container behind a reverse proxy or load balancer that handles TLS
|
||||
termination.
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a security vulnerability, please email contact@healthchecks.io.
|
|
@ -1,79 +0,0 @@
|
|||
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
|
||||
INTEGRATIONS_ALLOW_PRIVATE_IPS=False
|
||||
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
|
||||
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=
|
||||
ROCKETCHAT_ENABLED=True
|
||||
RP_ID=
|
||||
S3_ACCESS_KEY=
|
||||
S3_BUCKET=
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=
|
||||
S3_SECRET_KEY=
|
||||
S3_TIMEOUT=60
|
||||
S3_SECURE=True
|
||||
SECRET_KEY=---
|
||||
SHELL_ENABLED=False
|
||||
SIGNAL_CLI_SOCKET=
|
||||
SITE_LOGO_URL=
|
||||
SITE_NAME=Mychecks
|
||||
SITE_ROOT=http://localhost:8000
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
SLACK_ENABLED=True
|
||||
# SMTPD_PORT=
|
||||
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
|
||||
WHATSAPP_DOWN_CONTENT_SID=
|
||||
WHATSAPP_UP_CONTENT_SID=
|
||||
ZULIP_ENABLED=True
|
|
@ -1,49 +0,0 @@
|
|||
FROM python:3.13.1-slim-bookworm AS builder
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
COPY requirements.txt /tmp
|
||||
RUN \
|
||||
apt-get update && \
|
||||
apt-get install -y build-essential curl libpq-dev libmariadb-dev libffi-dev libpcre2-dev libssl-dev libcurl4-openssl-dev libpython3-dev pkg-config
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | bash -s -- --profile minimal -y
|
||||
ENV PATH=$PATH:/root/.cargo/bin
|
||||
RUN pip wheel --wheel-dir /wheels apprise uwsgi mysqlclient minio psycopg-c==3.2.3 -r /tmp/requirements.txt
|
||||
|
||||
COPY . /opt/healthchecks/
|
||||
RUN \
|
||||
rm -rf /opt/healthchecks/.git && \
|
||||
rm -rf /opt/healthchecks/stuff
|
||||
|
||||
FROM python:3.13.0-slim-bookworm
|
||||
|
||||
RUN useradd --system hc
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /opt/healthchecks
|
||||
|
||||
RUN \
|
||||
apt-get update && \
|
||||
apt-get install -y libcurl4 libexpat1 libpq5 libmariadb3 libxml2 && \
|
||||
rm -rf /var/apt/cache && \
|
||||
rm -rf /var/lib/apt/lists
|
||||
|
||||
|
||||
RUN --mount=type=bind,target=/wheels,source=/wheels,from=builder \
|
||||
pip install --upgrade pip && \
|
||||
pip install --no-cache /wheels/*
|
||||
|
||||
COPY --from=builder /opt/healthchecks/ /opt/healthchecks/
|
||||
COPY docker/fetchstatus.py /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
|
||||
|
||||
RUN mkdir /data && chown hc /data
|
||||
|
||||
USER hc
|
||||
|
||||
ENV USE_GZIP_MIDDLEWARE=True
|
||||
HEALTHCHECK --start-period=20s --start-interval=5s --interval=60s --retries=1 CMD ./fetchstatus.py
|
||||
CMD [ "uwsgi", "/opt/healthchecks/docker/uwsgi.ini"]
|
150
docker/README.md
150
docker/README.md
|
@ -1,150 +0,0 @@
|
|||
# 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: 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 does not handle TLS
|
||||
termination.
|
||||
|
||||
## Getting Started
|
||||
|
||||
* Copy `/docker/.env.example` to `/docker/.env` and add your configuration in it.
|
||||
As a minimum, set the following fields:
|
||||
* `ALLOWED_HOSTS` – the domain name of your Healthchecks instance.
|
||||
Example: `ALLOWED_HOSTS=hc.example.org`.
|
||||
* `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.
|
||||
* `SITE_ROOT` – The base public URL of your Healthchecks instance. Example:
|
||||
`SITE_ROOT=https://hc.example.org`.
|
||||
|
||||
* Create and start containers:
|
||||
|
||||
```sh
|
||||
docker compose up
|
||||
```
|
||||
|
||||
* Create a superuser:
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
## uWSGI Configuration
|
||||
|
||||
The reference Dockerfile uses [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/)
|
||||
as the WSGI server. You can configure uWSGI by setting `UWSGI_...` environment
|
||||
variables in `docker/.env`. For example, to disable HTTP request logging, set:
|
||||
|
||||
UWSGI_DISABLE_LOGGING=1
|
||||
|
||||
To adjust the number of uWSGI processes (for example, to save memory), set:
|
||||
|
||||
UWSGI_PROCESSES=2
|
||||
|
||||
Read more about configuring uWSGI in [uWSGI documentation](https://uwsgi-docs.readthedocs.io/en/latest/Configuration.html#environment-variables).
|
||||
|
||||
## SMTP Listener Configuration via `SMTPD_PORT`
|
||||
|
||||
Healthchecks comes with a `smtpd` management command, which runs a SMTP listener
|
||||
service. With the command running, you can ping your checks by sending email messages
|
||||
to `your-uuid-here@your-hc-domain.com` email addresses.
|
||||
|
||||
The container is configured to start the SMTP listener conditionally, based
|
||||
on the value of the `SMTPD_PORT` environment value:
|
||||
|
||||
* If `SMTPD_PORT` environment variable is not set, the SMTP listener will not run.
|
||||
* If `SMTPD_PORT` is set, the listener will run and listen on the specified port.
|
||||
You may also need to edit `docker-compose.yml` to expose the listening port
|
||||
(see the "ports" section under the "web" service in `docker-compose.yml`).
|
||||
|
||||
The conditional logic lives in uWSGI configuration file,
|
||||
[uwsgi.ini](https://github.com/healthchecks/healthchecks/blob/master/docker/uwsgi.ini).
|
||||
|
||||
## TLS Termination and CSRF Protection
|
||||
|
||||
If you plan to expose your Healthchecks instance to the public internet, make sure you
|
||||
put a TLS-terminating reverse proxy or load balancer in front of it.
|
||||
|
||||
**Important:** This Dockerfile uses uWSGI, which relies on the [X-Forwarded-Proto](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto)
|
||||
header to determine if a request is secure or not. Without this information you
|
||||
may run into HTTP 403 "CSRF verification failed." errors when using your Healthchecks
|
||||
instance. See [this issue comment](https://github.com/healthchecks/healthchecks/discussions/851#discussioncomment-6293396)
|
||||
for more information.
|
||||
|
||||
Make sure your TLS-terminating reverse proxy:
|
||||
|
||||
* Discards the `X-Forwarded-Proto` header sent by the end user.
|
||||
* Sets the `X-Forwarded-Proto` header value to match the protocol of the original request
|
||||
("http" or "https").
|
||||
|
||||
For example, in NGINX you can use the `$scheme` variable like so:
|
||||
|
||||
```
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
If you are using haproxy, you can do the same like so:
|
||||
|
||||
```
|
||||
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
||||
http-request set-header X-Forwarded-Proto http unless { ssl_fc }
|
||||
```
|
||||
|
||||
## Upgrading Database
|
||||
|
||||
When you upgrade the database version in `docker-compose.yml` (for example,
|
||||
from `postgres:12` to `postgres:16`), you will also need to upgrade your postgres
|
||||
data directory. One way to do this is using the
|
||||
[pgautoupgrade](https://hub.docker.com/r/pgautoupgrade/pgautoupgrade) container.
|
||||
|
||||
Steps:
|
||||
|
||||
* As the very first step, **take a full backup of your database**.
|
||||
* Stop the `db` and `web` containers: `docker compose stop`
|
||||
* Look up the name of the postgres data volume name using `docker volume ls`
|
||||
* Run `pgautoupgrade` like so:
|
||||
|
||||
```
|
||||
docker run --rm --name pgauto -it \
|
||||
--mount type=volume,source=<pg-volume-name-here>,target=/var/lib/postgresql/data \
|
||||
-e POSTGRES_PASSWORD=password \
|
||||
-e PGAUTO_ONESHOT=yes \
|
||||
pgautoupgrade/pgautoupgrade:16-bookworm
|
||||
```
|
||||
|
||||
* Update the `docker-compose.yml` file to use the `postgres:16` image
|
||||
* Start containers: `docker compose up`
|
||||
|
||||
## Pre-built Images
|
||||
|
||||
Pre-built Docker images, built from the Dockerfile in this directory, are available
|
||||
[on Docker Hub](https://hub.docker.com/r/healthchecks/healthchecks). The images are
|
||||
built automatically for every new release.
|
||||
|
||||
The Docker images:
|
||||
|
||||
* Support amd64, arm/v7 and arm64 architectures.
|
||||
* Use uWSGI as the web server. uWSGI is configured to perform database migrations
|
||||
on startup, and to run `sendalerts`, `sendreports`, and `smtpd` in the background.
|
||||
You do not need to run them separately. The SMTP listener (`manage.py smtpd`) is
|
||||
started conditionally, [based on the value of the `SMTPD_PORT` environment variable](https://github.com/healthchecks/healthchecks/tree/master/docker#smtp-listener-configuration-via-smtpd_port).
|
||||
* Ship with both PostgreSQL and MySQL database drivers.
|
||||
* Serve static files using the whitenoise library.
|
||||
* Have the apprise library preinstalled.
|
||||
* Do *not* handle TLS termination. In a production setup, you will want to put
|
||||
the Healthchecks container behind a reverse proxy or load balancer that handles TLS
|
||||
termination.
|
||||
|
||||
To use a pre-built image for Healthchecks version X.Y, in the `docker-compose.yml` file
|
||||
replace the "build" section with:
|
||||
|
||||
```text
|
||||
image: healthchecks/healthchecks:vX.Y
|
||||
```
|
|
@ -1,29 +0,0 @@
|
|||
volumes:
|
||||
db-data:
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=$DB_NAME
|
||||
- POSTGRES_PASSWORD=$DB_PASSWORD
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
# To use a pre-built image, remove the above "build" section
|
||||
# and uncomment the following line:
|
||||
# image: healthchecks/healthchecks:latest
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# To enable SMTP on port 2525, set SMTPD_PORT=2525 in .env
|
||||
# and uncomment the following line:
|
||||
# - "2525:2525"
|
||||
depends_on:
|
||||
- db
|
||||
command: bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; uwsgi /opt/healthchecks/docker/uwsgi.ini'
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""Probe the /api/v3/status/ endpoint and return status 0 if successful.
|
||||
|
||||
The /api/v3/status/ endpoint tests if the database connection is alive.
|
||||
This script is intended to be used in the Dockerfile, in the
|
||||
HEALTHCHECK instruction.
|
||||
|
||||
When making the HTTP request, we must pass a valid Host header and a valid
|
||||
path (in case the app is not running at the root of the domnain). To
|
||||
figure this out, we need to see `settings.SITE_ROOT`. Loading full
|
||||
Django settings is a heavy operation so instead we replicate the logic that
|
||||
settings.py uses for reading SITE_ROOT:
|
||||
|
||||
* Load it from `SITE_ROOT` environment variable
|
||||
* if hc/local_settings.py exists, import it and read it from there
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# Read SITE_ROOT from environment, same as settings.py would do:
|
||||
SITE_ROOT = os.getenv("SITE_ROOT", "http://localhost:8000")
|
||||
# If local_settings.py exists, load it from there
|
||||
if os.path.exists("hc/local_settings.py"):
|
||||
from hc import local_settings
|
||||
|
||||
SITE_ROOT = getattr(local_settings, "SITE_ROOT", SITE_ROOT)
|
||||
|
||||
parsed_site_root = urlparse(SITE_ROOT.removesuffix("/"))
|
||||
url = f"http://localhost:8000{parsed_site_root.path}/api/v3/status/"
|
||||
headers = {"Host": parsed_site_root.netloc}
|
||||
with urlopen(Request(url, headers=headers)) as response:
|
||||
assert response.status == 200
|
||||
|
||||
print("Status OK")
|
|
@ -1,36 +0,0 @@
|
|||
[uwsgi]
|
||||
strict
|
||||
master
|
||||
die-on-term
|
||||
http-socket = :8000
|
||||
harakiri = 10
|
||||
buffer-size = 32768
|
||||
post-buffering = 16192
|
||||
processes = 4
|
||||
if-env = UWSGI_PROCESSES
|
||||
processes = %(_)
|
||||
endif =
|
||||
auto-procname
|
||||
enable-threads
|
||||
threads = 1
|
||||
chdir = /opt/healthchecks
|
||||
module = hc.wsgi:application
|
||||
thunder-lock
|
||||
disable-write-exception
|
||||
|
||||
# workaround for https://github.com/unbit/uwsgi/issues/2299
|
||||
max-fd = 10000
|
||||
|
||||
# compression
|
||||
check-static = static-collected/
|
||||
static-gzip-dir = static-collected/CACHE
|
||||
|
||||
# Note: manage.py migrate will also run system checks
|
||||
hook-pre-app = exec:./manage.py migrate
|
||||
# Use "--skip-checks" to avoid running same checks 3 times
|
||||
attach-daemon = ./manage.py sendalerts --skip-checks
|
||||
attach-daemon = ./manage.py sendreports --loop --skip-checks
|
||||
|
||||
if-env = SMTPD_PORT
|
||||
attach-daemon = ./manage.py smtpd --port %(_) --skip-checks
|
||||
endif =
|
|
@ -1,350 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, datetime
|
||||
from typing import TypedDict
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import ModelAdmin
|
||||
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, F, QuerySet
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
from django.db.models import Count
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django_stubs_ext import WithAnnotations
|
||||
|
||||
from hc.accounts.models import Credential, Profile, Project
|
||||
|
||||
Lookups = Iterable[tuple[str, str]]
|
||||
from django.utils.safestring import mark_safe
|
||||
from hc.accounts.models import Profile
|
||||
from hc.api.models import Channel, Check
|
||||
|
||||
|
||||
def _format_usage(num_checks: int, num_channels: int) -> str:
|
||||
tmpl = ""
|
||||
class Fieldset:
|
||||
name = None
|
||||
fields = []
|
||||
|
||||
if num_checks == 0:
|
||||
tmpl += "{} checks, "
|
||||
elif num_checks == 1:
|
||||
tmpl += "{} check, "
|
||||
else:
|
||||
tmpl += "<strong>{} checks</strong>, "
|
||||
|
||||
if num_channels == 0:
|
||||
tmpl += "{} channels"
|
||||
elif num_channels == 1:
|
||||
tmpl += "{} channel"
|
||||
else:
|
||||
tmpl += "<strong>{} channels</strong>"
|
||||
|
||||
return format_html(tmpl, num_checks, num_channels)
|
||||
@classmethod
|
||||
def tuple(cls):
|
||||
return (cls.name, {"fields": cls.fields})
|
||||
|
||||
|
||||
class NumChecksFilter(admin.SimpleListFilter):
|
||||
title = "check count"
|
||||
|
||||
parameter_name = "num_checks"
|
||||
|
||||
def lookups(self, r: HttpRequest, model_admin: ModelAdmin[Profile]) -> Lookups:
|
||||
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, r: HttpRequest, qs: QuerySet[WithAnnotations[Profile, ProfileAnnotations]]
|
||||
) -> QuerySet[WithAnnotations[Profile, ProfileAnnotations]]:
|
||||
value = self.value()
|
||||
if value:
|
||||
qs = qs.filter(num_checks__gt=int(value))
|
||||
|
||||
return qs
|
||||
class ProfileFieldset(Fieldset):
|
||||
name = "User Profile"
|
||||
fields = ("email", "api_key", "current_team", "reports_allowed",
|
||||
"next_report_date", "nag_period", "next_nag_date",
|
||||
"token", "sort")
|
||||
|
||||
|
||||
class ProfileAnnotations(TypedDict):
|
||||
num_checks: int
|
||||
num_members: int
|
||||
plan: str
|
||||
class TeamFieldset(Fieldset):
|
||||
name = "Team"
|
||||
fields = ("team_name", "team_limit", "check_limit",
|
||||
"ping_log_limit", "sms_limit", "sms_sent", "last_sms_date")
|
||||
|
||||
|
||||
@admin.register(Profile)
|
||||
class ProfileAdmin(ModelAdmin[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_per_page = 30
|
||||
list_select_related = ("user",)
|
||||
list_display = (
|
||||
"id",
|
||||
"email",
|
||||
"checks",
|
||||
"projects",
|
||||
"date_joined",
|
||||
"last_active",
|
||||
"over_limit",
|
||||
"deletion",
|
||||
"invited",
|
||||
"sms",
|
||||
"reports",
|
||||
)
|
||||
list_filter = (
|
||||
"check_limit",
|
||||
NumChecksFilter,
|
||||
"last_active_date",
|
||||
"over_limit_date",
|
||||
"deletion_scheduled_date",
|
||||
"reports",
|
||||
)
|
||||
actions = (
|
||||
"login",
|
||||
"send_report",
|
||||
"send_nag",
|
||||
"remove_totp",
|
||||
"schedule_for_deletion",
|
||||
"unschedule_for_deletion",
|
||||
)
|
||||
list_filter = ("team_limit", "reports_allowed",
|
||||
"check_limit", "next_report_date")
|
||||
|
||||
_profile_fields = (
|
||||
"tz",
|
||||
"reports",
|
||||
"next_report_date",
|
||||
"nag_period",
|
||||
"next_nag_date",
|
||||
"token",
|
||||
"theme",
|
||||
"sort",
|
||||
)
|
||||
fieldsets = (ProfileFieldset.tuple(), TeamFieldset.tuple())
|
||||
|
||||
_limits_fields = (
|
||||
"team_limit",
|
||||
"check_limit",
|
||||
"ping_log_limit",
|
||||
"sms_limit",
|
||||
"sms_sent",
|
||||
"last_sms_date",
|
||||
"call_limit",
|
||||
"calls_sent",
|
||||
"last_call_date",
|
||||
)
|
||||
|
||||
_deletion_fields = (
|
||||
"over_limit_date",
|
||||
"deletion_notice_date",
|
||||
"deletion_scheduled_date",
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
("User Profile", {"fields": _profile_fields}),
|
||||
("Limits", {"fields": _limits_fields}),
|
||||
("Deletion", {"fields": _deletion_fields}),
|
||||
)
|
||||
|
||||
def get_queryset(self, request: HttpRequest) -> QuerySet[Profile]:
|
||||
def get_queryset(self, request):
|
||||
qs = super(ProfileAdmin, self).get_queryset(request)
|
||||
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"))
|
||||
qs = qs.annotate(Count("member", distinct=True))
|
||||
qs = qs.annotate(Count("user__check", distinct=True))
|
||||
return qs
|
||||
|
||||
def email(self, obj: WithAnnotations[Profile, ProfileAnnotations]) -> str:
|
||||
if obj.plan:
|
||||
return format_html("{} <span>{}</span>", obj.user.email, obj.plan)
|
||||
@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
|
||||
})
|
||||
|
||||
@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>
|
||||
%d of %d
|
||||
""" % (pct, num_checks, obj.check_limit)
|
||||
|
||||
def invited(self, obj):
|
||||
return "%d of %d" % (obj.member__count, obj.team_limit)
|
||||
|
||||
def sms(self, obj):
|
||||
return "%d of %d" % (obj.sms_sent, obj.sms_limit)
|
||||
|
||||
def email(self, obj):
|
||||
return obj.user.email
|
||||
|
||||
@admin.display(ordering="user__date_joined")
|
||||
def date_joined(self, obj: Profile) -> datetime:
|
||||
return obj.user.date_joined
|
||||
|
||||
@admin.display(ordering="last_active_date")
|
||||
def last_active(self, obj: Profile) -> date | None:
|
||||
if obj.last_active_date:
|
||||
return obj.last_active_date.date()
|
||||
return None
|
||||
class HcUserAdmin(UserAdmin):
|
||||
actions = ["send_report"]
|
||||
list_display = ('id', 'email', 'date_joined', 'engagement',
|
||||
'is_staff', 'checks')
|
||||
|
||||
@admin.display(ordering="over_limit_date")
|
||||
def over_limit(self, obj: Profile) -> date | None:
|
||||
if obj.over_limit_date:
|
||||
return obj.over_limit_date.date()
|
||||
return None
|
||||
|
||||
@admin.display(ordering="deletion_scheduled_date")
|
||||
def deletion(self, obj: Profile) -> date | None:
|
||||
if obj.deletion_scheduled_date:
|
||||
return obj.deletion_scheduled_date.date()
|
||||
return None
|
||||
|
||||
def projects(self, obj: Profile) -> str:
|
||||
return render_to_string("admin/profile_list_projects.html", {"profile": obj})
|
||||
|
||||
def checks(self, obj: WithAnnotations[Profile, ProfileAnnotations]) -> str:
|
||||
tmpl = "{} of {}"
|
||||
if obj.num_checks > 1:
|
||||
tmpl = "<b>%s</b>" % tmpl
|
||||
return format_html(tmpl, obj.num_checks, obj.check_limit)
|
||||
|
||||
def invited(self, obj: WithAnnotations[Profile, ProfileAnnotations]) -> str:
|
||||
return f"{obj.num_members} of {obj.team_limit}"
|
||||
|
||||
def sms(self, obj: Profile) -> str:
|
||||
return f"{obj.sms_sent} of {obj.sms_limit}"
|
||||
|
||||
def login(self, r: HttpRequest, qs: QuerySet[Profile]) -> HttpResponseRedirect:
|
||||
profile = qs.get()
|
||||
auth_login(r, profile.user, "hc.accounts.backends.EmailBackend")
|
||||
return redirect("hc-index")
|
||||
|
||||
def send_report(self, request: HttpRequest, qs: QuerySet[Profile]) -> None:
|
||||
for profile in qs:
|
||||
profile.send_report()
|
||||
|
||||
self.message_user(request, f"{len(qs)} email(s) sent")
|
||||
|
||||
def send_nag(self, request: HttpRequest, qs: QuerySet[Profile]) -> None:
|
||||
for profile in qs:
|
||||
profile.send_report(nag=True)
|
||||
|
||||
self.message_user(request, f"{len(qs)} email(s) sent")
|
||||
|
||||
@admin.action(description="Remove TOTP")
|
||||
def remove_totp(self, request: HttpRequest, qs: QuerySet[Profile]) -> None:
|
||||
for profile in qs:
|
||||
profile.totp = None
|
||||
profile.totp_created = None
|
||||
profile.save()
|
||||
|
||||
self.message_user(request, f"Removed TOTP for {len(qs)} profile(s)")
|
||||
|
||||
def schedule_for_deletion(self, r: HttpRequest, qs: QuerySet[Profile]) -> None:
|
||||
for profile in qs:
|
||||
profile.schedule_for_deletion()
|
||||
self.message_user(r, f"{len(qs)} user(s) scheduled for deletion")
|
||||
|
||||
def unschedule_for_deletion(self, r: HttpRequest, qs: QuerySet[Profile]) -> None:
|
||||
num_unscheduled = qs.update(deletion_scheduled_date=None)
|
||||
self.message_user(r, f"{num_unscheduled} user(s) unscheduled for deletion")
|
||||
|
||||
|
||||
class ProjectAnnotations(TypedDict):
|
||||
num_checks: int
|
||||
num_channels: int
|
||||
num_members: int
|
||||
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(ModelAdmin[Project]):
|
||||
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: HttpRequest) -> QuerySet[Project]:
|
||||
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: Project) -> str:
|
||||
if obj.name:
|
||||
return obj.name
|
||||
|
||||
return f"Default Project for {obj.owner.email}"
|
||||
|
||||
def users(self, obj: WithAnnotations[Project, ProjectAnnotations]) -> str:
|
||||
if obj.num_members == 0:
|
||||
return obj.owner.email
|
||||
else:
|
||||
return render_to_string("admin/project_list_team.html", {"project": obj})
|
||||
|
||||
def usage(self, obj: WithAnnotations[Project, ProjectAnnotations]) -> str:
|
||||
return _format_usage(obj.num_checks, obj.num_channels)
|
||||
|
||||
def switch(self, obj: Project) -> str:
|
||||
url = reverse("hc-checks", args=[obj.code])
|
||||
return format_html("<a href='{}'>Show Checks</a>", url)
|
||||
|
||||
|
||||
class UserAnnotations(TypedDict):
|
||||
num_checks: int
|
||||
num_channels: int
|
||||
last_active_date: datetime | None
|
||||
|
||||
|
||||
class HcUserAdmin(UserAdmin[User]):
|
||||
list_display = (
|
||||
"id",
|
||||
"email",
|
||||
"usage",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"last_active",
|
||||
"is_staff",
|
||||
)
|
||||
|
||||
list_display_links = ("id", "email")
|
||||
list_filter = ("last_login", "date_joined", "is_staff", "is_active")
|
||||
actions = ("activate", "deactivate")
|
||||
|
||||
ordering = ["-id"]
|
||||
|
||||
def get_queryset(self, request: HttpRequest) -> QuerySet[User]:
|
||||
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))
|
||||
qs = qs.annotate(last_active_date=F("profile__last_active_date"))
|
||||
@mark_safe
|
||||
def engagement(self, user):
|
||||
result = ""
|
||||
num_checks = Check.objects.filter(user=user).count()
|
||||
num_channels = Channel.objects.filter(user=user).count()
|
||||
|
||||
return qs
|
||||
if num_checks == 0:
|
||||
result += "0 checks, "
|
||||
elif num_checks == 1:
|
||||
result += "1 check, "
|
||||
else:
|
||||
result += "<strong>%d checks</strong>, " % num_checks
|
||||
|
||||
def last_active(
|
||||
self, user: WithAnnotations[User, UserAnnotations]
|
||||
) -> datetime | None:
|
||||
assert (
|
||||
isinstance(user.last_active_date, datetime) or user.last_active_date is None
|
||||
)
|
||||
return user.last_active_date
|
||||
if num_channels == 0:
|
||||
result += "0 channels"
|
||||
elif num_channels == 1:
|
||||
result += "1 channel, "
|
||||
else:
|
||||
result += "<strong>%d channels</strong>, " % num_channels
|
||||
|
||||
def usage(self, user: WithAnnotations[User, UserAnnotations]) -> str:
|
||||
return _format_usage(user.num_checks, user.num_channels)
|
||||
return result
|
||||
|
||||
def activate(self, request: HttpRequest, qs: QuerySet[User]) -> None:
|
||||
@mark_safe
|
||||
def checks(self, user):
|
||||
url = reverse("hc-switch-team", args=[user.username])
|
||||
return "<a href='%s'>Checks</a>" % url
|
||||
|
||||
def send_report(self, request, qs):
|
||||
for user in qs:
|
||||
user.is_active = True
|
||||
user.save()
|
||||
user.profile.send_report()
|
||||
|
||||
self.message_user(request, f"{len(qs)} user(s) activated")
|
||||
|
||||
def deactivate(self, request: HttpRequest, qs: QuerySet[User]) -> None:
|
||||
for user in qs:
|
||||
user.is_active = False
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
self.message_user(request, f"{len(qs)} user(s) deactivated")
|
||||
self.message_user(request, "%d email(s) sent" % qs.count())
|
||||
|
||||
|
||||
admin.site.unregister(User)
|
||||
admin.site.register(User, HcUserAdmin)
|
||||
|
||||
|
||||
@admin.register(Credential)
|
||||
class CredentialAdmin(ModelAdmin[Credential]):
|
||||
list_display = ("id", "created", "email", "name")
|
||||
search_fields = ["id", "code", "name", "user__email"]
|
||||
list_filter = ["created"]
|
||||
readonly_fields = ("user",)
|
||||
|
||||
def email(self, obj: Credential) -> str:
|
||||
return obj.user.email
|
||||
|
|
|
@ -1,92 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import HttpRequest
|
||||
|
||||
from hc.accounts.models import Profile
|
||||
from hc.accounts.views import _make_user
|
||||
|
||||
|
||||
class BasicBackend:
|
||||
def get_user(self, user_id: int) -> User | None:
|
||||
class BasicBackend(object):
|
||||
|
||||
def get_user(self, user_id):
|
||||
try:
|
||||
q = User.objects.select_related("profile")
|
||||
|
||||
return q.get(pk=user_id)
|
||||
return User.objects.select_related("profile").get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
# Authenticate against the token in user's profile.
|
||||
class ProfileBackend(BasicBackend):
|
||||
def authenticate(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
username: str | None = None,
|
||||
token: str | None = None,
|
||||
) -> User | None:
|
||||
if not token:
|
||||
return None
|
||||
|
||||
def authenticate(self, request=None, username=None, token=None):
|
||||
try:
|
||||
profiles = Profile.objects.select_related("user")
|
||||
profile = profiles.get(user__username=username)
|
||||
except Profile.DoesNotExist:
|
||||
return None
|
||||
|
||||
if not profile.check_token(token):
|
||||
if not profile.check_token(token, "login"):
|
||||
return None
|
||||
|
||||
return profile.user
|
||||
|
||||
|
||||
class EmailBackend(BasicBackend):
|
||||
def authenticate(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> User | None:
|
||||
if not password:
|
||||
return None
|
||||
|
||||
def authenticate(self, request=None, username=None, password=None):
|
||||
try:
|
||||
user = User.objects.get(email=username)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
|
||||
if not user.check_password(password):
|
||||
return None
|
||||
|
||||
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: HttpRequest, remote_user_email: str | None = None
|
||||
) -> User | None:
|
||||
"""
|
||||
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
|
||||
if user.check_password(password):
|
||||
return user
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from django.core.signing import SignatureExpired, TimestampSigner
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from hc.api.models import TokenBucket
|
||||
from hc.lib import emails
|
||||
from hc.lib.typealias import ViewFunc
|
||||
|
||||
|
||||
def _session_unsign(request: HttpRequest, key: str, max_age: int) -> str | None:
|
||||
if key not in request.session:
|
||||
return None
|
||||
|
||||
try:
|
||||
return TimestampSigner().unsign(request.session[key], max_age=max_age)
|
||||
except SignatureExpired:
|
||||
return None
|
||||
|
||||
|
||||
def require_sudo_mode(f: ViewFunc) -> ViewFunc:
|
||||
@wraps(f)
|
||||
def wrapper(request: HttpRequest, *args: Any, **kwds: Any) -> HttpResponse:
|
||||
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
|
|
@ -1,115 +1,68 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta as td
|
||||
from typing import Any
|
||||
|
||||
from django import forms
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import HttpRequest
|
||||
from pyotp.totp import TOTP
|
||||
|
||||
from hc.accounts.models import REPORT_CHOICES, Member
|
||||
from hc.api.models import TokenBucket
|
||||
from hc.lib.tz import all_timezones
|
||||
|
||||
|
||||
class LowercaseEmailField(forms.EmailField):
|
||||
def clean(self, value: str) -> str:
|
||||
|
||||
def clean(self, value):
|
||||
value = super(LowercaseEmailField, self).clean(value)
|
||||
return value.lower()
|
||||
|
||||
|
||||
class SignupForm(forms.Form):
|
||||
class AvailableEmailForm(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 __init__(self, request: HttpRequest):
|
||||
self.request = request
|
||||
super(SignupForm, self).__init__(request.POST)
|
||||
|
||||
def clean_identity(self) -> str:
|
||||
if not TokenBucket.authorize_auth_ip(self.request):
|
||||
raise forms.ValidationError("Too many attempts, please try later.")
|
||||
identity = LowercaseEmailField(error_messages={'required': 'Please enter your email address.'})
|
||||
|
||||
def clean_identity(self):
|
||||
v = self.cleaned_data["identity"]
|
||||
assert isinstance(v, str)
|
||||
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) -> str | None:
|
||||
assert isinstance(self.cleaned_data["tz"], str)
|
||||
|
||||
# Declare tz as "clean" only if we can find it in hc.lib.tz.all_timezones
|
||||
if self.cleaned_data["tz"] in 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.
|
||||
return None
|
||||
|
||||
|
||||
class EmailLoginForm(forms.Form):
|
||||
class ExistingEmailForm(forms.Form):
|
||||
# Call it "identity" instead of "email"
|
||||
# to avoid some of the dumber bots
|
||||
identity = LowercaseEmailField()
|
||||
|
||||
def __init__(self, request: HttpRequest | None = None):
|
||||
self.request = request
|
||||
super(EmailLoginForm, self).__init__(request.POST if request else None)
|
||||
|
||||
def clean_identity(self) -> str:
|
||||
def clean_identity(self):
|
||||
v = self.cleaned_data["identity"]
|
||||
|
||||
assert isinstance(v, str)
|
||||
if not TokenBucket.authorize_login_email(v):
|
||||
raise forms.ValidationError("Too many attempts, please try later.")
|
||||
|
||||
assert self.request
|
||||
if not TokenBucket.authorize_auth_ip(self.request):
|
||||
raise forms.ValidationError("Too many attempts, please try later.")
|
||||
|
||||
self.user: User | None
|
||||
try:
|
||||
self.user = User.objects.get(email=v)
|
||||
except User.DoesNotExist:
|
||||
self.user = None
|
||||
raise forms.ValidationError("Incorrect email address.")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class PasswordLoginForm(forms.Form):
|
||||
class EmailPasswordForm(forms.Form):
|
||||
email = LowercaseEmailField()
|
||||
password = forms.CharField()
|
||||
|
||||
def clean(self) -> dict[str, Any]:
|
||||
username = self.cleaned_data.get("email")
|
||||
password = self.cleaned_data.get("password")
|
||||
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.")
|
||||
if self.user is None:
|
||||
raise forms.ValidationError("Incorrect email or password")
|
||||
if not self.user.is_active:
|
||||
raise forms.ValidationError("Account is inactive")
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class ReportSettingsForm(forms.Form):
|
||||
reports = forms.ChoiceField(choices=REPORT_CHOICES)
|
||||
reports_allowed = forms.BooleanField(required=False)
|
||||
nag_period = forms.IntegerField(min_value=0, max_value=86400)
|
||||
tz = forms.CharField()
|
||||
|
||||
def clean_nag_period(self) -> td:
|
||||
def clean_nag_period(self):
|
||||
seconds = self.cleaned_data["nag_period"]
|
||||
|
||||
if seconds not in (0, 3600, 86400):
|
||||
|
@ -117,30 +70,17 @@ class ReportSettingsForm(forms.Form):
|
|||
|
||||
return td(seconds=seconds)
|
||||
|
||||
def clean_tz(self) -> str | None:
|
||||
assert isinstance(self.cleaned_data["tz"], str)
|
||||
|
||||
# Declare tz as "clean" only if we can find it in hc.lib.tz.all_timezones
|
||||
if self.cleaned_data["tz"] in 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.
|
||||
return None
|
||||
|
||||
|
||||
class SetPasswordForm(forms.Form):
|
||||
password = forms.CharField(min_length=8)
|
||||
password = forms.CharField()
|
||||
|
||||
|
||||
class ChangeEmailForm(forms.Form):
|
||||
error_css_class = "has-error"
|
||||
email = LowercaseEmailField()
|
||||
|
||||
def clean_email(self) -> str:
|
||||
def clean_email(self):
|
||||
v = self.cleaned_data["email"]
|
||||
assert isinstance(v, str)
|
||||
if User.objects.filter(email=v).exists():
|
||||
raise forms.ValidationError("%s is already registered" % v)
|
||||
|
||||
|
@ -148,42 +88,12 @@ class ChangeEmailForm(forms.Form):
|
|||
|
||||
|
||||
class InviteTeamMemberForm(forms.Form):
|
||||
email = LowercaseEmailField(max_length=254)
|
||||
role = forms.ChoiceField(choices=Member.Role.choices)
|
||||
email = LowercaseEmailField()
|
||||
|
||||
|
||||
class RemoveTeamMemberForm(forms.Form):
|
||||
email = LowercaseEmailField()
|
||||
|
||||
|
||||
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)
|
||||
response = forms.CharField()
|
||||
|
||||
|
||||
class WebAuthnForm(forms.Form):
|
||||
response = forms.CharField()
|
||||
|
||||
|
||||
class TotpForm(forms.Form):
|
||||
error_css_class = "has-error"
|
||||
code = forms.RegexField(regex=r"^\d{6}$")
|
||||
|
||||
def __init__(self, totp: TOTP, post: Any = None):
|
||||
self.totp = totp
|
||||
super(TotpForm, self).__init__(post)
|
||||
|
||||
def clean_code(self) -> str:
|
||||
assert isinstance(self.cleaned_data["code"], str)
|
||||
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"]
|
||||
class TeamNameForm(forms.Form):
|
||||
team_name = forms.CharField(max_length=200, required=True)
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import HttpRequest
|
||||
|
||||
from hc.accounts.models import Profile
|
||||
|
||||
|
||||
class AuthenticatedHttpRequest(HttpRequest):
|
||||
user: User
|
||||
profile: Profile
|
20
hc/accounts/management/commands/createreadonlykeys.py
Normal file
20
hc/accounts/management/commands/createreadonlykeys.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from base64 import urlsafe_b64encode
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from hc.accounts.models import Profile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Create read-only API keys."""
|
||||
|
||||
def handle(self, *args, **options):
|
||||
c = 0
|
||||
q = Profile.objects.filter(api_key_readonly="").exclude(api_key="")
|
||||
for profile in q:
|
||||
profile.api_key_readonly = urlsafe_b64encode(os.urandom(24)).decode()
|
||||
profile.save()
|
||||
c += 1
|
||||
|
||||
return "Done! Generated %d readonly keys." % c
|
|
@ -1,51 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from getpass import getpass
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from hc.accounts.forms import LowercaseEmailField
|
||||
from hc.accounts.views import _make_user
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Create a super-user account."""
|
||||
|
||||
def handle(self, **options: Any) -> str:
|
||||
email = None
|
||||
password = None
|
||||
|
||||
while not email:
|
||||
raw = input("Email address:")
|
||||
try:
|
||||
email = LowercaseEmailField().clean(raw)
|
||||
except ValidationError as e:
|
||||
self.stderr.write("Error: " + " ".join(e.messages))
|
||||
continue
|
||||
if User.objects.filter(email=email).exists():
|
||||
self.stderr.write(f"Error: email {email} is already taken")
|
||||
email = None
|
||||
continue
|
||||
|
||||
while not password:
|
||||
p1 = getpass()
|
||||
p2 = 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."
|
|
@ -1,14 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta as td
|
||||
from typing import Any
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count, F
|
||||
from django.utils.timezone import now
|
||||
|
||||
from hc.accounts.models import Profile
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
@ -22,26 +17,13 @@ class Command(BaseCommand):
|
|||
|
||||
"""
|
||||
|
||||
def handle(self, **options: Any) -> str:
|
||||
month_ago = now() - td(days=30)
|
||||
def handle(self, *args, **options):
|
||||
cutoff = timezone.now() - timedelta(days=30)
|
||||
|
||||
# Old accounts, never logged in, no team memberships
|
||||
q = User.objects.order_by("id")
|
||||
q = q.annotate(n_teams=Count("memberships"))
|
||||
q = q.filter(date_joined__lt=month_ago, last_login=None, n_teams=0)
|
||||
q = q.filter(date_joined__lt=cutoff, last_login=None, n_teams=0)
|
||||
|
||||
n, summary = q.delete()
|
||||
count = summary.get("auth.User", 0)
|
||||
self.stdout.write("Pruned %d never-logged-in user accounts." % count)
|
||||
|
||||
# Profiles scheduled for deletion
|
||||
pq = Profile.objects.order_by("id")
|
||||
pq = pq.filter(deletion_notice_date__lt=month_ago)
|
||||
# Exclude users who have logged in after receiving deletion notice
|
||||
pq = pq.exclude(user__last_login__gt=F("deletion_notice_date"))
|
||||
|
||||
for profile in pq:
|
||||
self.stdout.write("Deleting inactive %s" % profile.user.email)
|
||||
profile.user.delete()
|
||||
|
||||
return "Done!"
|
||||
return "Done! Pruned %d user accounts." % summary.get("auth.User", 0)
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import timedelta as td
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.timezone import now
|
||||
|
||||
from hc.accounts.models import Profile
|
||||
from hc.api.models import Channel, Check, Flip
|
||||
from hc.lib import emails
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Send warnings to accounts marked for deletion. """
|
||||
|
||||
def pause(self) -> None:
|
||||
time.sleep(1)
|
||||
|
||||
def members(self, user: User) -> QuerySet[User]:
|
||||
q = User.objects.filter(memberships__project__owner=user)
|
||||
q = q.exclude(last_login=None)
|
||||
return q.order_by("email")
|
||||
|
||||
def send_channel_notifications(
|
||||
self, profile: Profile, skip_emails: list[str]
|
||||
) -> None:
|
||||
# Sending deletion notices to configured notification channels is
|
||||
# a last ditch effort: only do this if 14 or fewer days are left.
|
||||
assert profile.deletion_scheduled_date
|
||||
delta = profile.deletion_scheduled_date - now()
|
||||
if delta.days > 14:
|
||||
return
|
||||
|
||||
formatted = profile.deletion_scheduled_date.strftime("%B %-d, %Y")
|
||||
name = f"{settings.SITE_NAME} Account Deletion on {formatted}"
|
||||
desc = (
|
||||
f"The {settings.SITE_NAME} account registered to {profile.user.email} "
|
||||
f"is scheduled for deletion on {formatted}. To keep the account, "
|
||||
f"please contact {settings.SUPPORT_EMAIL} ASAP."
|
||||
)
|
||||
for channel in Channel.objects.filter(project__owner_id=profile.user_id):
|
||||
if channel.kind == "email" and channel.email.value in skip_emails:
|
||||
continue
|
||||
|
||||
dummy = Check(name=name, desc=desc, status="down", project=channel.project)
|
||||
dummy.last_ping = now() - td(days=1)
|
||||
dummy.n_pings = 1
|
||||
|
||||
dummy_flip = Flip(owner=dummy)
|
||||
dummy_flip.created = now()
|
||||
dummy_flip.old_status = "up"
|
||||
dummy_flip.new_status = "down"
|
||||
|
||||
self.stdout.write(f" * Sending notification to {channel.kind}")
|
||||
error = channel.notify(dummy_flip, is_test=True)
|
||||
if error == "no-op":
|
||||
# This channel may be configured to send "up" notifications only.
|
||||
dummy.status = "up"
|
||||
error = channel.notify(dummy_flip, is_test=True)
|
||||
|
||||
if error:
|
||||
self.stdout.write(f" Error sending notification: {error}")
|
||||
|
||||
def handle(self, **options: Any) -> str:
|
||||
q = Profile.objects.order_by("id")
|
||||
q = q.filter(deletion_scheduled_date__gt=now())
|
||||
|
||||
sent = 0
|
||||
for profile in q:
|
||||
recipients = [profile.user.email]
|
||||
# Include team members in the recipient list too:
|
||||
for u in self.members(profile.user):
|
||||
if u.email not in recipients:
|
||||
recipients.append(u.email)
|
||||
|
||||
self.stdout.write(f"Sending notice to {recipients}")
|
||||
ctx = {
|
||||
"owner_email": profile.user.email,
|
||||
"num_checks": profile.num_checks_used(),
|
||||
"support_email": settings.SUPPORT_EMAIL,
|
||||
"deletion_scheduled_date": profile.deletion_scheduled_date,
|
||||
}
|
||||
emails.deletion_scheduled(recipients, ctx)
|
||||
self.send_channel_notifications(profile, skip_emails=recipients)
|
||||
sent += 1
|
||||
|
||||
# Throttle so we don't send too many emails at once:
|
||||
self.pause()
|
||||
|
||||
return f"Done!\nNotices sent: {sent}\n"
|
|
@ -1,90 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import timedelta as td
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import now
|
||||
|
||||
from hc.accounts.models import Member, Profile
|
||||
from hc.api.models import Ping
|
||||
from hc.lib import emails
|
||||
|
||||
YEAR_AGO = now() - td(days=365)
|
||||
|
||||
|
||||
def has_projects_with_active_members(profile: Profile) -> bool:
|
||||
q = Member.objects.filter(project__owner_id=profile.user_id)
|
||||
recent_signup = Q(user__date_joined__gt=YEAR_AGO)
|
||||
recent_login = Q(user__last_login__gt=YEAR_AGO)
|
||||
recent_activity = Q(user__profile__last_active_date__gt=YEAR_AGO)
|
||||
q = q.filter(recent_signup | recent_login | recent_activity)
|
||||
return q.exists()
|
||||
|
||||
|
||||
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 active team members
|
||||
- none of the owned projects has pings in the last year
|
||||
- is on a free plan
|
||||
|
||||
"""
|
||||
|
||||
def pause(self) -> None:
|
||||
time.sleep(1)
|
||||
|
||||
def handle(self, **options: Any) -> str:
|
||||
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 accounts with subscriptions
|
||||
q = q.exclude(user__subscription__subscription_id__gt="")
|
||||
|
||||
sent = 0
|
||||
skipped_has_team = 0
|
||||
skipped_has_pings = 0
|
||||
|
||||
for profile in q:
|
||||
if has_projects_with_active_members(profile):
|
||||
# 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"
|
||||
)
|
|
@ -1,83 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import MiddlewareNotUsed
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from hc.accounts.models import Profile
|
||||
|
||||
MiddlewareFunc = Callable[[HttpRequest], HttpResponse]
|
||||
|
||||
|
||||
class TeamAccessMiddleware:
|
||||
def __init__(self, get_response: MiddlewareFunc) -> None:
|
||||
class TeamAccessMiddleware(object):
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
def __call__(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return self.get_response(request)
|
||||
|
||||
setattr(request, "profile", Profile.objects.for_user(request.user))
|
||||
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)
|
||||
|
||||
|
||||
class CustomHeaderMiddleware:
|
||||
"""
|
||||
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 __init__(self, get_response: MiddlewareFunc) -> None:
|
||||
if not settings.REMOTE_USER_HEADER:
|
||||
raise MiddlewareNotUsed()
|
||||
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
assert settings.REMOTE_USER_HEADER
|
||||
# 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 self.get_response(request)
|
||||
|
||||
# The email address from the external authentication system may be in
|
||||
# in upper case or mixed case. Convert it to lower case, as we do
|
||||
# elsewhere in the system (when registering new users, when inviting users
|
||||
# into projects, when changing email address) to avoid naming conflicts.
|
||||
email = email.lower()
|
||||
|
||||
# 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 self.get_response(request)
|
||||
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.
|
||||
if user := auth.authenticate(request, remote_user_email=email):
|
||||
assert isinstance(user, 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)
|
||||
request.profile = Profile.objects.for_user(request.user)
|
||||
request.team = request.profile.team()
|
||||
|
||||
return self.get_response(request)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.conf import settings
|
||||
|
@ -5,32 +7,18 @@ 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)),
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-01-04 20:26
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-02-16 12:14
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-05-09 08:01
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
@ -9,46 +11,34 @@ 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),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-05-09 10:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
@ -6,16 +8,14 @@ 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'),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-05-07 13:04
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-06-08 11:58
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-07-14 17:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-09-02 11:52
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-09-12 14:24
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,29 +1,26 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-10-14 10:02
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from datetime import timedelta as td
|
||||
import datetime
|
||||
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=[
|
||||
(td(0), "Disabled"),
|
||||
(td(0, 3600), "Hourly"),
|
||||
(td(1), "Daily"),
|
||||
],
|
||||
default=td(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),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-10-14 16:15
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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',
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-12-27 15:30
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
@ -7,16 +9,14 @@ 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),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -5,17 +5,19 @@ from django.db import migrations, models
|
|||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0014_auto_20171227_1530")]
|
||||
dependencies = [
|
||||
('accounts', '0014_auto_20171227_1530'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="api_key_id",
|
||||
model_name='profile',
|
||||
name='api_key_id',
|
||||
field=models.CharField(blank=True, max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="api_key_readonly",
|
||||
model_name='profile',
|
||||
name='api_key_readonly',
|
||||
field=models.CharField(blank=True, max_length=128),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -5,6 +5,13 @@ from django.db import migrations
|
|||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0015_auto_20181029_1858")]
|
||||
dependencies = [
|
||||
('accounts', '0015_auto_20181029_1858'),
|
||||
]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="profile", name="bill_to")]
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='bill_to',
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,37 +0,0 @@
|
|||
# Generated by Django 2.1.5 on 2019-01-11 14:49
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_projects(apps: Apps, schema_editor: Any) -> None:
|
||||
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)]
|
|
@ -1,16 +0,0 @@
|
|||
# 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),
|
||||
)
|
||||
]
|
|
@ -1,21 +0,0 @@
|
|||
# Generated by Django 2.1.5 on 2019-01-12 19:50
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_badge_key(apps: Apps, schema_editor: Any) -> None:
|
||||
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)]
|
|
@ -1,16 +0,0 @@
|
|||
# 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),
|
||||
)
|
||||
]
|
|
@ -1,19 +0,0 @@
|
|||
# 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"
|
||||
),
|
||||
)
|
||||
]
|
|
@ -1,14 +0,0 @@
|
|||
# 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"),
|
||||
]
|
|
@ -1,13 +0,0 @@
|
|||
# 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"),
|
||||
]
|
|
@ -1,10 +0,0 @@
|
|||
# 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")]
|
|
@ -1,27 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,16 +0,0 @@
|
|||
# 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),
|
||||
)
|
||||
]
|
|
@ -1,23 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,17 +0,0 @@
|
|||
# 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",
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,28 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,19 +0,0 @@
|
|||
# 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"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,43 +0,0 @@
|
|||
# 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -1,22 +0,0 @@
|
|||
# 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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,22 +0,0 @@
|
|||
# Generated by Django 3.2.2 on 2021-05-24 07:38
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fill_reports_field(apps: Apps, schema_editor: Any) -> None:
|
||||
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)]
|
|
@ -1,18 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,17 +0,0 @@
|
|||
# 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",
|
||||
),
|
||||
]
|
|
@ -1,25 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,24 +0,0 @@
|
|||
# Generated by Django 3.2.4 on 2021-07-22 13:25
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fill_member_role(apps: Apps, schema_editor: Any) -> None:
|
||||
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),
|
||||
]
|
|
@ -1,17 +0,0 @@
|
|||
# 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",
|
||||
),
|
||||
]
|
|
@ -1,19 +0,0 @@
|
|||
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
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,23 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,23 +0,0 @@
|
|||
# Generated by Django 3.2.6 on 2021-09-08 12:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0044_auto_20210730_0942"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="ping_key",
|
||||
field=models.CharField(blank=True, max_length=128, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="show_slugs",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 4.2 on 2023-04-28 11:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0045_auto_20210908_1257"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="deletion_scheduled_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2023-06-16 13:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0046_profile_deletion_scheduled_date"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="over_limit_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -1,23 +0,0 @@
|
|||
# Generated by Django 4.2.4 on 2023-08-29 13:16
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("accounts", "0047_profile_over_limit_date"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="profile",
|
||||
name="user",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,46 +0,0 @@
|
|||
# Generated by Django 5.1.1 on 2024-10-24 08:53
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.core.management import CommandError
|
||||
from django.db import migrations
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
|
||||
def convert_emails(apps: Apps, schema_editor: Any) -> None:
|
||||
User = apps.get_model("auth", "User")
|
||||
# A queryset of users with non-lowercase email addresses
|
||||
problematic_users = User.objects.exclude(email=Lower("email"))
|
||||
|
||||
# For each affected user, check if their normalized email address would
|
||||
# conflict with another user's normalized email address.
|
||||
# The situation we want to protect against is where before migration we have:
|
||||
# * Alice@Example.Org
|
||||
# * ALICE@EXAMPLE.ORG
|
||||
# And after migration we have
|
||||
# * alice@example.org
|
||||
# * alice@example.org
|
||||
# (Two accounts with the same email address).
|
||||
for u in problematic_users:
|
||||
q = User.objects.exclude(id=u.id).filter(email__iexact=u.email)
|
||||
if conflicting_user := q.first():
|
||||
raise CommandError(
|
||||
f"Cannot convert {u.email} to lower case because of an existing "
|
||||
f"account with a conflicting email address: {conflicting_user.email}"
|
||||
)
|
||||
|
||||
# If no conflicts, go ahead and do a mass update
|
||||
problematic_users.update(email=Lower("email"))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0048_alter_profile_user"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(convert_emails, migrations.RunPython.noop),
|
||||
]
|
|
@ -1,494 +1,233 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from datetime import timedelta as td
|
||||
from secrets import token_urlsafe
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import quote, urlencode
|
||||
from zoneinfo import ZoneInfo
|
||||
from base64 import urlsafe_b64encode
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
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 BadSignature, TimestampSigner
|
||||
from django.core.signing import TimestampSigner
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.models.functions import Lower
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
|
||||
from django.utils import timezone
|
||||
from hc.lib import emails
|
||||
from hc.lib.date import month_boundaries, week_boundaries
|
||||
from hc.lib.signing import sign_bounce_id
|
||||
from hc.lib.urls import absolute_reverse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Importing Check at runtime would cause a circular import, so only import it
|
||||
# during type checking
|
||||
from hc.api.models import Check
|
||||
|
||||
CheckQuerySet = QuerySet[Check]
|
||||
|
||||
|
||||
NO_NAG = td()
|
||||
NAG_PERIODS = (
|
||||
(NO_NAG, "Disabled"),
|
||||
(td(hours=1), "Hourly"),
|
||||
(td(days=1), "Daily"),
|
||||
)
|
||||
|
||||
REPORT_CHOICES = (("off", "Off"), ("weekly", "Weekly"), ("monthly", "Monthly"))
|
||||
# How long an account can be over limits before it is scheduled for deletion
|
||||
OVER_LIMIT_GRACE = td(days=31)
|
||||
# When scheduling for deletion, how many days in the future to schedule
|
||||
DELETION_GRACE = td(days=31)
|
||||
NO_NAG = timedelta()
|
||||
NAG_PERIODS = ((NO_NAG, "Disabled"),
|
||||
(timedelta(hours=1), "Hourly"),
|
||||
(timedelta(days=1), "Daily"))
|
||||
|
||||
|
||||
def month(dt: datetime) -> date:
|
||||
"""For a given datetime, return the matching first-day-of-month date."""
|
||||
def month(dt):
|
||||
""" For a given datetime, return the matching first-day-of-month date. """
|
||||
return dt.date().replace(day=1)
|
||||
|
||||
|
||||
class ProfileManager(models.Manager["Profile"]):
|
||||
def for_user(self, user: User) -> Profile:
|
||||
class ProfileManager(models.Manager):
|
||||
def for_user(self, user):
|
||||
try:
|
||||
return user.profile
|
||||
except Profile.DoesNotExist:
|
||||
profile = Profile(user=user)
|
||||
if not settings.USE_PAYMENTS:
|
||||
# If not using payments, set high limits
|
||||
profile.check_limit = 10000
|
||||
profile.sms_limit = 10000
|
||||
profile.call_limit = 10000
|
||||
profile.team_limit = 10000
|
||||
profile.check_limit = 500
|
||||
profile.sms_limit = 500
|
||||
profile.team_limit = 500
|
||||
|
||||
profile.save()
|
||||
return profile
|
||||
|
||||
|
||||
class Profile(models.Model):
|
||||
user = models.OneToOneField(User, models.CASCADE)
|
||||
# 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 = models.CharField(max_length=10, default="monthly", choices=REPORT_CHOICES)
|
||||
reports_allowed = models.BooleanField(default=True)
|
||||
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_id = models.CharField(max_length=128, blank=True)
|
||||
api_key = models.CharField(max_length=128, blank=True)
|
||||
api_key_readonly = models.CharField(max_length=128, blank=True)
|
||||
current_team = models.ForeignKey("self", models.SET_NULL, null=True)
|
||||
last_sms_date = models.DateTimeField(null=True, blank=True)
|
||||
sms_limit = models.IntegerField(default=5)
|
||||
sms_limit = models.IntegerField(default=0)
|
||||
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")
|
||||
# The date when "Inactive Account Notification" is sent
|
||||
deletion_notice_date = models.DateTimeField(null=True, blank=True)
|
||||
# Set manually by admin, causes an orange banner in web UI
|
||||
deletion_scheduled_date = models.DateTimeField(null=True, blank=True)
|
||||
# If the account is over its check limit, the date when it went over the limit
|
||||
over_limit_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) -> str:
|
||||
return f"Profile for {self.user.email}"
|
||||
def __str__(self):
|
||||
return self.team_name or self.user.email
|
||||
|
||||
def notifications_url(self) -> str:
|
||||
return absolute_reverse("hc-notifications")
|
||||
def notifications_url(self):
|
||||
return settings.SITE_ROOT + reverse("hc-notifications")
|
||||
|
||||
def reports_unsub_url(self) -> str:
|
||||
def reports_unsub_url(self):
|
||||
signer = TimestampSigner(salt="reports")
|
||||
signed_username = signer.sign(self.user.username)
|
||||
return absolute_reverse("hc-unsubscribe-reports", args=[signed_username])
|
||||
path = reverse("hc-unsubscribe-reports", args=[signed_username])
|
||||
return settings.SITE_ROOT + path
|
||||
|
||||
def prepare_token(self) -> str:
|
||||
token = token_urlsafe(24)
|
||||
# Store a hashed transformation of the login token
|
||||
self.token = make_password(token, "login")
|
||||
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()
|
||||
self.token = make_password(token, salt)
|
||||
self.save()
|
||||
# Sign the token so we can check its age later
|
||||
return TimestampSigner().sign(token)
|
||||
return token
|
||||
|
||||
def check_token(self, token: str) -> bool:
|
||||
try:
|
||||
token = TimestampSigner().unsign(token, max_age=3600)
|
||||
except BadSignature:
|
||||
return False
|
||||
|
||||
return "login" in self.token and check_password(token, self.token)
|
||||
|
||||
def send_instant_login_link(
|
||||
self, membership: Member | None = None, redirect_url: str | None = None
|
||||
) -> None:
|
||||
token = self.prepare_token()
|
||||
url = absolute_reverse("hc-check-token", args=[self.user.username, token])
|
||||
if redirect_url:
|
||||
url += "?next=%s" % redirect_url
|
||||
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):
|
||||
token = self.prepare_token("login")
|
||||
path = reverse("hc-check-token", args=[self.user.username, token])
|
||||
ctx = {
|
||||
"button_text": "Log In",
|
||||
"button_url": url,
|
||||
"membership": membership,
|
||||
"button_url": settings.SITE_ROOT + path,
|
||||
"inviting_profile": inviting_profile
|
||||
}
|
||||
emails.login(self.user.email, ctx)
|
||||
|
||||
def send_change_email_link(self, new_email: str) -> None:
|
||||
payload = {
|
||||
"u": self.user.username,
|
||||
"t": self.prepare_token(),
|
||||
"e": new_email,
|
||||
}
|
||||
signed_payload = TimestampSigner().sign_object(payload)
|
||||
url = absolute_reverse("hc-change-email-verify", args=[signed_payload])
|
||||
|
||||
def send_set_password_link(self):
|
||||
token = self.prepare_token("set-password")
|
||||
path = reverse("hc-set-password", args=[token])
|
||||
ctx = {
|
||||
"button_text": "Log In",
|
||||
"button_url": url,
|
||||
"button_text": "Set Password",
|
||||
"button_url": settings.SITE_ROOT + path
|
||||
}
|
||||
emails.login(new_email, ctx)
|
||||
|
||||
def send_transfer_request(self, project: Project) -> None:
|
||||
token = self.prepare_token()
|
||||
settings_path = reverse("hc-project-settings", args=[project.code])
|
||||
url = absolute_reverse("hc-check-token", args=[self.user.username, token])
|
||||
url += f"?next={settings_path}"
|
||||
emails.set_password(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": "Project Settings",
|
||||
"button_url": url,
|
||||
"project": project,
|
||||
"button_text": "Change Email",
|
||||
"button_url": settings.SITE_ROOT + path
|
||||
}
|
||||
emails.transfer_request(self.user.email, ctx)
|
||||
emails.change_email(self.user.email, ctx)
|
||||
|
||||
def send_sms_limit_notice(self, transport: str) -> None:
|
||||
ctx = {"transport": transport, "limit": self.sms_limit}
|
||||
if self.sms_limit != 500 and settings.USE_PAYMENTS:
|
||||
ctx["url"] = absolute_reverse("hc-pricing")
|
||||
def set_api_keys(self, key_id=""):
|
||||
self.api_key_id = key_id
|
||||
self.api_key = urlsafe_b64encode(os.urandom(24)).decode()
|
||||
self.api_key_readonly = urlsafe_b64encode(os.urandom(24)).decode()
|
||||
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) -> None:
|
||||
ctx: dict[str, Any] = {"limit": self.call_limit}
|
||||
if self.call_limit != 500 and settings.USE_PAYMENTS:
|
||||
ctx["url"] = absolute_reverse("hc-pricing")
|
||||
|
||||
emails.call_limit(self.user.email, ctx)
|
||||
|
||||
def projects(self) -> QuerySet[Project]:
|
||||
"""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(Lower("name"))
|
||||
|
||||
def checks_from_all_projects(self) -> CheckQuerySet:
|
||||
"""Return a queryset of checks from projects we have access to."""
|
||||
team_ids = set(self.user.memberships.values_list("team_id", flat=True))
|
||||
team_ids.add(self.id)
|
||||
|
||||
from hc.api.models import Check
|
||||
return Check.objects.filter(user__profile__id__in=team_ids)
|
||||
|
||||
return Check.objects.filter(project__in=self.projects())
|
||||
|
||||
def send_report(self, nag: bool = False) -> bool:
|
||||
q = self.checks_from_all_projects()
|
||||
def send_report(self, nag=False):
|
||||
checks = self.checks_from_all_teams()
|
||||
|
||||
# Has there been a ping in last 6 months?
|
||||
result = q.aggregate(models.Max("last_ping"))
|
||||
result = checks.aggregate(models.Max("last_ping"))
|
||||
last_ping = result["last_ping__max"]
|
||||
|
||||
six_months_ago = now() - td(days=180)
|
||||
six_months_ago = timezone.now() - timedelta(days=180)
|
||||
if last_ping is None or last_ping < six_months_ago:
|
||||
return False
|
||||
|
||||
# Sort checks by project. Need this because will group by project in template.
|
||||
q = q.select_related("project").order_by("project_id")
|
||||
# list() executes the query, to avoid DB access while rendering the template.
|
||||
checks = list(q)
|
||||
# Is there at least one check that is down?
|
||||
num_down = checks.filter(status="down").count()
|
||||
if nag and num_down == 0:
|
||||
return False
|
||||
|
||||
# Sort checks by owner. Need this because will group by owner in
|
||||
# template.
|
||||
checks = checks.select_related("user", "user__profile")
|
||||
checks = checks.order_by("user_id")
|
||||
# list() executes the query, to avoid DB access while
|
||||
# rendering the template
|
||||
checks = list(checks)
|
||||
|
||||
unsub_url = self.reports_unsub_url()
|
||||
|
||||
headers = {
|
||||
"X-Bounce-ID": sign_bounce_id("r.%s" % self.user.username),
|
||||
"List-Unsubscribe": "<%s>" % unsub_url,
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
"List-Unsubscribe": unsub_url,
|
||||
"X-Bounce-Url": unsub_url
|
||||
}
|
||||
ctx: dict[str, Any] = {
|
||||
|
||||
ctx = {
|
||||
"checks": checks,
|
||||
"sort": self.sort,
|
||||
"now": timezone.now(),
|
||||
"unsub_link": unsub_url,
|
||||
"notifications_url": self.notifications_url(),
|
||||
"tz": self.tz,
|
||||
"nag": nag,
|
||||
"nag_period": self.nag_period.total_seconds(),
|
||||
"num_down": num_down
|
||||
}
|
||||
|
||||
if not nag:
|
||||
# For weekly and monthly reports, calculate the downtimes,
|
||||
# throw away the current period, keep two previous periods
|
||||
if self.reports == "weekly":
|
||||
boundaries = week_boundaries(3, self.tz)
|
||||
else:
|
||||
boundaries = month_boundaries(3, self.tz)
|
||||
|
||||
for check in checks:
|
||||
downtimes = check.downtimes_by_boundary(boundaries, self.tz)
|
||||
# downtimes_by_boundary returns records in descending order,
|
||||
# but the template will need them in ascending order:
|
||||
downtimes.reverse()
|
||||
setattr(check, "past_downtimes", downtimes[:-1])
|
||||
|
||||
# boundaries are in descending order, but the template
|
||||
# will need them in ascending order:
|
||||
boundaries.reverse()
|
||||
ctx["checks"] = checks
|
||||
ctx["boundaries"] = boundaries[:-1]
|
||||
ctx["monthly_or_weekly"] = self.reports
|
||||
emails.report(self.user.email, ctx, headers)
|
||||
|
||||
if nag:
|
||||
# For nags, only show checks that are currently down
|
||||
checks = [c for c in checks if c.get_status() == "down"]
|
||||
if not checks:
|
||||
return False
|
||||
ctx["checks"] = checks
|
||||
ctx["num_down"] = len(checks)
|
||||
ctx["nag_period"] = self.nag_period.total_seconds()
|
||||
emails.nag(self.user.email, ctx, headers)
|
||||
|
||||
emails.report(self.user.email, ctx, headers)
|
||||
return True
|
||||
|
||||
def sms_sent_this_month(self) -> int:
|
||||
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:
|
||||
return 0
|
||||
|
||||
# If last sent date is not from this month, we've sent 0 this month.
|
||||
if month(now()) > month(self.last_sms_date):
|
||||
if month(timezone.now()) > month(self.last_sms_date):
|
||||
return 0
|
||||
|
||||
return self.sms_sent
|
||||
|
||||
def authorize_sms(self) -> bool:
|
||||
"""If monthly limit not exceeded, increase counter and return True"""
|
||||
def authorize_sms(self):
|
||||
""" If monthly limit not exceeded, increase counter and return True """
|
||||
|
||||
sent_this_month = self.sms_sent_this_month()
|
||||
if sent_this_month >= self.sms_limit:
|
||||
return False
|
||||
|
||||
self.sms_sent = sent_this_month + 1
|
||||
self.last_sms_date = now()
|
||||
self.last_sms_date = timezone.now()
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def calls_sent_this_month(self) -> int:
|
||||
# IF last_call_date was never set, we have not made any phone calls yet.
|
||||
if not self.last_call_date:
|
||||
return 0
|
||||
def set_next_nag_date(self):
|
||||
""" Set next_nag_date for all members of this team. """
|
||||
|
||||
# If last sent date is not from this month, we've made 0 calls this month.
|
||||
if month(now()) > month(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)
|
||||
|
||||
return self.calls_sent
|
||||
|
||||
def authorize_call(self) -> bool:
|
||||
"""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 = now()
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def num_checks_used(self) -> int:
|
||||
from hc.api.models import Check
|
||||
|
||||
return Check.objects.filter(project__owner_id=self.user_id).count()
|
||||
|
||||
def num_checks_available(self) -> int:
|
||||
return self.check_limit - self.num_checks_used()
|
||||
|
||||
def can_accept(self, project: Project) -> bool:
|
||||
return project.check_set.count() <= self.num_checks_available()
|
||||
|
||||
def update_next_nag_date(self) -> None:
|
||||
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 = 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) -> datetime | None:
|
||||
"""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
|
||||
|
||||
dt = now().astimezone(ZoneInfo(self.tz))
|
||||
dt = dt.replace(hour=9, minute=0) + td(minutes=random.randrange(0, 120))
|
||||
|
||||
while True:
|
||||
dt += td(days=1)
|
||||
if self.reports == "monthly" and dt.day == 1:
|
||||
return dt
|
||||
elif self.reports == "weekly" and dt.weekday() == 0:
|
||||
return dt
|
||||
|
||||
def is_past_over_limit_grace(self) -> bool:
|
||||
"""Return True if this profile is over limits for 31 or more days."""
|
||||
if not self.over_limit_date:
|
||||
return False
|
||||
|
||||
return now() > self.over_limit_date + OVER_LIMIT_GRACE
|
||||
|
||||
def schedule_for_deletion(self) -> None:
|
||||
self.deletion_scheduled_date = now() + DELETION_GRACE
|
||||
self.save()
|
||||
|
||||
|
||||
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)
|
||||
ping_key = models.CharField(max_length=128, blank=True, null=True, unique=True)
|
||||
show_slugs = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.owner.email
|
||||
|
||||
@property
|
||||
def owner_profile(self) -> Profile:
|
||||
return Profile.objects.for_user(self.owner)
|
||||
|
||||
def num_checks_available(self) -> int:
|
||||
return self.owner_profile.num_checks_available()
|
||||
|
||||
def invite_suggestions(self) -> QuerySet[User]:
|
||||
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) -> bool:
|
||||
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: User, role: str) -> bool:
|
||||
if Member.objects.filter(user=user, project=self).exists():
|
||||
return False
|
||||
|
||||
if self.owner_id == user.id:
|
||||
return False
|
||||
|
||||
m = Member.objects.create(user=user, project=self, role=role)
|
||||
checks_url = reverse("hc-checks", args=[self.code])
|
||||
|
||||
if settings.EMAIL_HOST:
|
||||
profile = Profile.objects.for_user(user)
|
||||
profile.send_instant_login_link(membership=m, redirect_url=checks_url)
|
||||
return True
|
||||
|
||||
def update_next_nag_dates(self) -> None:
|
||||
"""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()
|
||||
|
||||
return None
|
||||
|
||||
def get_n_down(self) -> int:
|
||||
result = 0
|
||||
for check in self.check_set.all():
|
||||
if check.get_status() == "down":
|
||||
result += 1
|
||||
|
||||
return result
|
||||
|
||||
def have_channel_issues(self) -> bool:
|
||||
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) -> Member | None:
|
||||
return self.member_set.filter(transfer_request_date__isnull=False).first()
|
||||
|
||||
def dashboard_url(self) -> str | None:
|
||||
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) -> str:
|
||||
return absolute_reverse("hc-checks", args=[self.code])
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("hc-checks", args=[self.code])
|
||||
q.update(next_nag_date=timezone.now() + models.F("nag_period"))
|
||||
|
||||
|
||||
class Member(models.Model):
|
||||
class Role(models.TextChoices):
|
||||
READONLY = "r", "Read-only"
|
||||
REGULAR = "w", "Member"
|
||||
MANAGER = "m", "Manager"
|
||||
|
||||
team = models.ForeignKey(Profile, models.CASCADE)
|
||||
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) -> bool:
|
||||
return self.user.profile.can_accept(self.project)
|
||||
|
||||
@property
|
||||
def is_rw(self) -> bool:
|
||||
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()
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from hc.accounts.models import Project
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class AddProjectTestCase(BaseTestCase):
|
||||
def test_it_works(self) -> None:
|
||||
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) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get("/projects/add/")
|
||||
self.assertEqual(r.status_code, 405)
|
|
@ -1,85 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class AddTotpTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.url = "/accounts/two_factor/totp/"
|
||||
|
||||
def test_it_requires_sudo_mode(self) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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")
|
|
@ -1,94 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from hc.accounts.models import Credential
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
@override_settings(RP_ID="testserver")
|
||||
class AddWebauthnTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.url = "/accounts/two_factor/webauthn/"
|
||||
|
||||
def test_it_requires_sudo_mode(self) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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.CreateHelper.verify")
|
||||
def test_it_adds_credential(self, mock_verify: Mock) -> None:
|
||||
mock_verify.return_value = b"dummy-credential-data"
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy state"
|
||||
session.save()
|
||||
|
||||
payload = {"name": "My New Key", "response": "dummy response"}
|
||||
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")
|
||||
|
||||
# state should have been removed from the session
|
||||
self.assertNotIn("state", self.client.session)
|
||||
|
||||
def test_it_handles_bad_response_json(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy state"
|
||||
session.save()
|
||||
|
||||
payload = {"name": "My New Key", "response": "this is not json"}
|
||||
r = self.client.post(self.url, payload)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@patch("hc.accounts.views.logger")
|
||||
@patch("hc.accounts.views.CreateHelper.verify")
|
||||
def test_it_handles_verification_failure(self, verify: Mock, logger: Mock) -> None:
|
||||
verify.side_effect = ValueError
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy state"
|
||||
session.save()
|
||||
|
||||
payload = {"name": "My New Key", "response": "dummy response"}
|
||||
|
||||
r = self.client.post(self.url, payload, follow=True)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
# It should log the verification failure
|
||||
self.assertTrue(logger.exception.called)
|
|
@ -1,57 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from hc.payments.models import Subscription
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class AccountsAdminTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
def setUp(self):
|
||||
super(AccountsAdminTestCase, self).setUp()
|
||||
|
||||
self.alice.is_staff = True
|
||||
self.alice.is_superuser = True
|
||||
self.alice.save()
|
||||
|
||||
def test_it_shows_profiles(self) -> None:
|
||||
def test_it_shows_profiles(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/admin/accounts/profile/")
|
||||
self.assertContains(r, "alice@example.org")
|
||||
self.assertContains(r, "bob@example.org")
|
||||
|
||||
def test_it_escapes_emails_when_showing_profiles(self) -> None:
|
||||
self.bob.email = "bob&friends@example.org"
|
||||
self.bob.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get("/admin/accounts/profile/")
|
||||
# The amperstand should be escaped
|
||||
self.assertNotContains(r, "bob&friends@example.org")
|
||||
|
||||
def test_it_escapes_emails_when_showing_profiles_with_subscriptions(self) -> None:
|
||||
self.bob.email = "bob&friends@example.org"
|
||||
self.bob.save()
|
||||
|
||||
self.sub = Subscription(user=self.bob)
|
||||
self.sub.plan_name = "Custom Plan"
|
||||
self.sub.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get("/admin/accounts/profile/")
|
||||
# The amperstand should be escaped
|
||||
self.assertNotContains(r, "bob&friends@example.org")
|
||||
self.assertContains(r, "<span>Custom Plan</span>")
|
||||
|
||||
def test_it_shows_projects(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get("/admin/accounts/project/")
|
||||
self.assertContains(r, "Alices Project")
|
||||
self.assertContains(r, "Default Project for bob@example.org")
|
||||
|
||||
def test_it_escapes_emails_when_showing_projects(self) -> None:
|
||||
self.bob.email = "bob&friends@example.org"
|
||||
self.bob.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get("/admin/accounts/project/")
|
||||
# The amperstand should be escaped
|
||||
self.assertNotContains(r, "bob&friends@example.org")
|
||||
|
|
20
hc/accounts/tests/test_badges.py
Normal file
20
hc/accounts/tests/test_badges.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
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")
|
|
@ -1,75 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import mail
|
||||
from django.core.mail import EmailMessage, EmailMultiAlternatives
|
||||
from django.test.utils import override_settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class ChangeEmailTestCase(BaseTestCase):
|
||||
def get_html(self, email: EmailMessage) -> str:
|
||||
assert isinstance(email, EmailMultiAlternatives)
|
||||
html, _ = email.alternatives[0]
|
||||
assert isinstance(html, str)
|
||||
return html
|
||||
|
||||
def test_it_requires_sudo_mode(self) -> None:
|
||||
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")
|
||||
|
||||
r = self.client.get("/accounts/change_email/")
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
def test_it_shows_form(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get("/accounts/change_email/")
|
||||
r = self.client.get("/accounts/change_email/foo/")
|
||||
self.assertContains(r, "Change Account's Email Address")
|
||||
|
||||
@override_settings(SITE_ROOT="http://testserver", SESSION_COOKIE_SECURE=False)
|
||||
def test_it_sends_link(self) -> None:
|
||||
def test_it_changes_password(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()
|
||||
|
||||
payload = {"email": "alice2@example.org"}
|
||||
r = self.client.post("/accounts/change_email/", payload, follow=True)
|
||||
self.assertRedirects(r, "/accounts/change_email/")
|
||||
self.assertContains(r, "One Last Step")
|
||||
self.client.post("/accounts/change_email/foo/", payload)
|
||||
|
||||
self.assertEqual(self.client.cookies["auto-login"].value, "1")
|
||||
self.assertEqual(self.client.cookies["auto-login"]["samesite"], "Lax")
|
||||
self.assertTrue(self.client.cookies["auto-login"]["httponly"])
|
||||
self.assertFalse(self.client.cookies["auto-login"]["secure"])
|
||||
|
||||
# The email address should have not changed yet
|
||||
self.alice.refresh_from_db()
|
||||
self.assertEqual(self.alice.email, "alice@example.org")
|
||||
self.assertTrue(self.alice.has_usable_password())
|
||||
self.assertEqual(self.alice.email, "alice2@example.org")
|
||||
self.assertFalse(self.alice.has_usable_password())
|
||||
|
||||
# And email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
message = mail.outbox[0]
|
||||
self.assertEqual(message.subject, f"Log in to {settings.SITE_NAME}")
|
||||
html = self.get_html(message)
|
||||
self.assertIn("http://testserver/accounts/change_email/", html)
|
||||
def test_it_requires_unique_email(self):
|
||||
self.profile.token = make_password("foo", "change-email")
|
||||
self.profile.save()
|
||||
|
||||
@override_settings(SESSION_COOKIE_SECURE=True)
|
||||
def test_it_sets_secure_autologin_cookie(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"email": "alice2@example.org"}
|
||||
r = self.client.post("/accounts/change_email/", payload)
|
||||
self.assertTrue(r.cookies["auto-login"]["secure"])
|
||||
|
||||
def test_it_requires_unique_email(self) -> None:
|
||||
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/", payload)
|
||||
r = self.client.post("/accounts/change_email/foo/", payload)
|
||||
self.assertContains(r, "bob@example.org is already registered")
|
||||
|
||||
self.alice.refresh_from_db()
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.signing import TimestampSigner
|
||||
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class ChangeEmailVerifyTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.profile.token = make_password("secret-token", "login")
|
||||
self.profile.save()
|
||||
|
||||
self.checks_url = f"/projects/{self.project.code}/checks/"
|
||||
|
||||
def _url(self, expired: bool = False) -> str:
|
||||
payload = {
|
||||
"u": self.alice.username,
|
||||
"t": TimestampSigner().sign("secret-token"),
|
||||
"e": "alice+new@example.org",
|
||||
}
|
||||
|
||||
if expired:
|
||||
with patch("django.core.signing.TimestampSigner.timestamp") as mock_ts:
|
||||
mock_ts.return_value = "1kHR5c"
|
||||
signed = TimestampSigner().sign_object(payload)
|
||||
else:
|
||||
signed = TimestampSigner().sign_object(payload)
|
||||
|
||||
return f"/accounts/change_email/{signed}/"
|
||||
|
||||
def test_it_works(self) -> None:
|
||||
r = self.client.post(self._url())
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
# Alice's email should have been updated, and password cleared
|
||||
self.alice.refresh_from_db()
|
||||
self.assertEqual(self.alice.email, "alice+new@example.org")
|
||||
self.assertFalse(self.alice.has_usable_password())
|
||||
|
||||
# After login, token should be blank
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.token, "")
|
||||
|
||||
def test_it_handles_get(self) -> None:
|
||||
r = self.client.get(self._url())
|
||||
self.assertContains(r, "You are about to log into")
|
||||
|
||||
# Alice's email should have *not* been changed yet
|
||||
self.alice.refresh_from_db()
|
||||
self.assertEqual(self.alice.email, "alice@example.org")
|
||||
|
||||
def test_it_handles_get_with_cookie(self) -> None:
|
||||
self.client.cookies["auto-login"] = "1"
|
||||
r = self.client.get(self._url())
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
def test_it_handles_expired_link(self) -> None:
|
||||
r = self.client.post(self._url(expired=True))
|
||||
self.assertContains(r, "The link you just used is incorrect.")
|
||||
|
||||
def test_it_handles_bad_payload(self) -> None:
|
||||
r = self.client.post("/accounts/change_email/bad-payload/")
|
||||
self.assertContains(r, "The link you just used is incorrect.")
|
||||
|
||||
def test_it_handles_unavailable_email(self) -> None:
|
||||
# Make the target address unavailable
|
||||
User.objects.create(email="alice+new@example.org")
|
||||
|
||||
r = self.client.post(self._url(), follow=True)
|
||||
self.assertRedirects(r, "/accounts/login/")
|
||||
self.assertContains(r, "incorrect or expired")
|
||||
|
||||
# Alice's email should have *not* been updated
|
||||
self.alice.refresh_from_db()
|
||||
self.assertEqual(self.alice.email, "alice@example.org")
|
|
@ -1,87 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.signing import TimestampSigner
|
||||
|
||||
from hc.accounts.models import Credential
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class CheckTokenTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
def setUp(self):
|
||||
super(CheckTokenTestCase, self).setUp()
|
||||
self.profile.token = make_password("secret-token", "login")
|
||||
self.profile.save()
|
||||
|
||||
signed_token = TimestampSigner().sign("secret-token")
|
||||
self.url = f"/accounts/check_token/alice/{signed_token}/"
|
||||
self.checks_url = "/projects/%s/checks/" % self.project.code
|
||||
|
||||
def test_it_shows_form(self) -> None:
|
||||
r = self.client.get(self.url)
|
||||
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) -> None:
|
||||
r = self.client.post(self.url)
|
||||
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
def test_it_redirects(self):
|
||||
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
||||
self.assertRedirects(r, "/checks/")
|
||||
|
||||
# After login, token should be blank
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.token, "")
|
||||
|
||||
def test_it_handles_email_in_username(self) -> None:
|
||||
# Healthchecks will generate usernames that look like UUIDs. But custom
|
||||
# authentication backends like django-auth-ldap can also create User objects
|
||||
# with non-UUID usernames. In this testcase we check if check_token works
|
||||
# with an username that looks like an email address.
|
||||
self.alice.username = "alice@example.org"
|
||||
self.alice.save()
|
||||
r = self.client.post(self.url.replace("alice", "alice@example.org"))
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
def test_it_handles_get_with_cookie(self) -> None:
|
||||
self.client.cookies["auto-login"] = "1"
|
||||
r = self.client.get(self.url)
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
def test_it_redirects_already_logged_in(self) -> None:
|
||||
def test_it_redirects_already_logged_in(self):
|
||||
# Login
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
# Login again, when already authenticated
|
||||
r = self.client.post(self.url)
|
||||
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) -> None:
|
||||
def test_it_redirects_bad_login(self):
|
||||
# Login with a bad token
|
||||
url = "/accounts/check_token/alice/invalid-token/"
|
||||
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) -> None:
|
||||
url = self.url + "?next=" + self.channels_url
|
||||
r = self.client.post(url)
|
||||
self.assertRedirects(r, self.channels_url)
|
||||
|
||||
def test_it_ignores_bad_next_parameter(self) -> None:
|
||||
url = self.url + "?next=/evil/"
|
||||
r = self.client.post(url)
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
def test_it_redirects_to_webauthn_form(self) -> None:
|
||||
Credential.objects.create(user=self.alice, name="Alices Key")
|
||||
|
||||
r = self.client.post(self.url)
|
||||
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)
|
||||
|
|
|
@ -1,77 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/close/")
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
def test_it_shows_confirmation_form(self) -> None:
|
||||
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")
|
||||
|
||||
def test_it_works(self) -> None:
|
||||
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"
|
||||
)
|
||||
@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")
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"confirmation": "alice@example.org"}
|
||||
r = self.client.post("/accounts/close/", payload, follow=True)
|
||||
self.assertRedirects(r, "/accounts/login/")
|
||||
self.assertContains(r, "Account closed.")
|
||||
r = self.client.post("/accounts/close/")
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
# 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)
|
||||
|
||||
# Subscription should be gone
|
||||
self.assertFalse(Subscription.objects.exists())
|
||||
|
||||
def test_it_requires_confirmation(self) -> None:
|
||||
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) -> None:
|
||||
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, "/accounts/login/")
|
||||
r = self.client.post("/accounts/close/")
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
# 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)
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from hc.accounts.management.commands.createsuperuser import Command
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class CreateSuperuserTestCase(BaseTestCase):
|
||||
def test_it_works(self) -> None:
|
||||
cmd = Command(stdout=Mock())
|
||||
with patch(cmd.__module__ + ".input") as mock_input:
|
||||
with patch(cmd.__module__ + ".getpass") as mock_getpass:
|
||||
mock_input.return_value = "superuser@example.org"
|
||||
mock_getpass.return_value = "hunter2"
|
||||
cmd.handle()
|
||||
|
||||
u = User.objects.get(email="superuser@example.org")
|
||||
self.assertTrue(u.is_superuser)
|
||||
|
||||
def test_it_rejects_duplicate_email(self) -> None:
|
||||
cmd = Command(stdout=Mock(), stderr=Mock())
|
||||
with patch(cmd.__module__ + ".input") as mock_input:
|
||||
with patch(cmd.__module__ + ".getpass") as mock_getpass:
|
||||
mock_input.side_effect = ["alice@example.org", "alice2@example.org"]
|
||||
mock_getpass.return_value = "hunter2"
|
||||
cmd.handle()
|
||||
|
||||
u = User.objects.get(email="alice2@example.org")
|
||||
self.assertTrue(u.is_superuser)
|
|
@ -1,247 +1,73 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.core.mail import EmailMessage, EmailMultiAlternatives
|
||||
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
|
||||
from django.test import TestCase
|
||||
from hc.accounts.models import Profile
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class LoginTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.checks_url = f"/projects/{self.project.code}/checks/"
|
||||
class LoginTestCase(TestCase):
|
||||
|
||||
def get_html(self, email: EmailMessage) -> str:
|
||||
assert isinstance(email, EmailMultiAlternatives)
|
||||
html, _ = email.alternatives[0]
|
||||
assert isinstance(html, str)
|
||||
return html
|
||||
def test_it_sends_link(self):
|
||||
alice = User(username="alice", email="alice@example.org")
|
||||
alice.save()
|
||||
|
||||
def test_it_shows_form(self) -> None:
|
||||
r = self.client.get("/accounts/login/")
|
||||
self.assertContains(r, "magic-link-form")
|
||||
# It should not show validation errors yet
|
||||
self.assertNotContains(r, "This field is required")
|
||||
|
||||
@override_settings(EMAIL_HOST=None)
|
||||
def test_it_handles_no_smtp(self) -> None:
|
||||
r = self.client.get("/accounts/login/")
|
||||
self.assertNotContains(r, "magic-link-form")
|
||||
|
||||
def test_it_redirects_authenticated_get(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/login/")
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
@override_settings(
|
||||
SITE_ROOT="http://testserver", SITE_LOGO_URL=None, SESSION_COOKIE_SECURE=False
|
||||
)
|
||||
def test_it_sends_link(self) -> None:
|
||||
form = {"identity": "alice@example.org"}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(r, "/accounts/login_link_sent/")
|
||||
assert r.status_code == 302
|
||||
|
||||
self.assertEqual(r.cookies["auto-login"].value, "1")
|
||||
self.assertTrue(r.cookies["auto-login"]["httponly"])
|
||||
self.assertEqual(r.cookies["auto-login"]["samesite"], "Lax")
|
||||
self.assertFalse(r.cookies["auto-login"]["secure"])
|
||||
# Alice should be the only existing user
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
|
||||
# And email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
message = mail.outbox[0]
|
||||
self.assertEqual(message.subject, f"Log in to {settings.SITE_NAME}")
|
||||
html = self.get_html(message)
|
||||
self.assertIn("http://testserver/static/img/logo.png", html)
|
||||
self.assertIn("http://testserver/docs/", html)
|
||||
subject = "Log in to %s" % settings.SITE_NAME
|
||||
self.assertEqual(mail.outbox[0].subject, subject)
|
||||
|
||||
@override_settings(SESSION_COOKIE_SECURE=True)
|
||||
def test_it_sets_secure_autologin_cookie(self) -> None:
|
||||
form = {"identity": "alice@example.org"}
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertTrue(r.cookies["auto-login"]["secure"])
|
||||
|
||||
@override_settings(SITE_LOGO_URL="https://example.org/logo.svg")
|
||||
def test_it_uses_custom_logo(self) -> None:
|
||||
self.client.post("/accounts/login/", {"identity": "alice@example.org"})
|
||||
html = self.get_html(mail.outbox[0])
|
||||
self.assertIn("https://example.org/logo.svg", html)
|
||||
|
||||
def test_it_sends_link_with_next(self) -> None:
|
||||
form = {"identity": "alice@example.org"}
|
||||
|
||||
r = self.client.post("/accounts/login/?next=" + self.channels_url, form)
|
||||
self.assertRedirects(r, "/accounts/login_link_sent/")
|
||||
|
||||
# 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)
|
||||
|
||||
def test_it_handles_unknown_email(self) -> None:
|
||||
form = {"identity": "surprise@example.org"}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(r, "/accounts/login_link_sent/")
|
||||
# It should send the same response and cookies as in normal login
|
||||
self.assertEqual(r.cookies["auto-login"].value, "1")
|
||||
|
||||
# There should be no sent emails.
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
@override_settings(SECRET_KEY="test-secret")
|
||||
def test_it_rate_limits_emails(self) -> None:
|
||||
# "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_rate_limits_client_ips(self) -> None:
|
||||
obj = TokenBucket(value="auth-ip-127.0.0.1")
|
||||
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_rate_limiter_uses_x_forwarded_for(self) -> None:
|
||||
obj = TokenBucket(value="auth-ip-127.0.0.2")
|
||||
obj.tokens = 0
|
||||
obj.save()
|
||||
|
||||
form = {"identity": "alice@example.org"}
|
||||
xff = "127.0.0.2:1234,127.0.0.3"
|
||||
r = self.client.post("/accounts/login/", form, HTTP_X_FORWARDED_FOR=xff)
|
||||
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) -> None:
|
||||
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_ignores_case(self) -> None:
|
||||
def test_it_ignores_case(self):
|
||||
alice = User(username="alice", email="alice@example.org")
|
||||
alice.save()
|
||||
|
||||
form = {"identity": "ALICE@EXAMPLE.ORG"}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(r, "/accounts/login_link_sent/")
|
||||
assert r.status_code == 302
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertTrue(self.profile.token)
|
||||
# There should be exactly one user:
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
|
||||
def test_it_handles_password(self) -> None:
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
profile = Profile.objects.for_user(alice)
|
||||
self.assertIn("login", profile.token)
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
def test_it_handles_password(self):
|
||||
alice = User(username="alice", email="alice@example.org")
|
||||
alice.set_password("password")
|
||||
alice.save()
|
||||
|
||||
@override_settings(SECRET_KEY="test-secret")
|
||||
def test_it_rate_limits_password_attempts(self) -> None:
|
||||
# "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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
form = {
|
||||
"action": "login",
|
||||
"email": "alice@example.org",
|
||||
"password": "wrong password",
|
||||
"password": "password"
|
||||
}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
def test_it_handles_wrong_password(self):
|
||||
alice = User(username="alice", email="alice@example.org")
|
||||
alice.set_password("password")
|
||||
alice.save()
|
||||
|
||||
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) -> None:
|
||||
r = self.client.get("/accounts/login/")
|
||||
self.assertNotContains(r, "Create Your Account")
|
||||
|
||||
def test_it_redirects_to_webauthn_form(self) -> None:
|
||||
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)
|
||||
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)
|
||||
|
||||
def test_it_redirects_to_totp_form(self) -> None:
|
||||
self.profile.totp = "0" * 32
|
||||
self.profile.save()
|
||||
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(
|
||||
r, "/accounts/login/two_factor/totp/", 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)
|
||||
|
||||
def test_it_handles_missing_profile(self) -> None:
|
||||
self.profile.delete()
|
||||
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
|
|
@ -1,99 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from hc.api.models import TokenBucket
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class LoginTotpTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
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) -> None:
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Please enter the six-digit code")
|
||||
|
||||
def test_it_requires_unauthenticated_user(self) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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")
|
|
@ -1,117 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
@override_settings(RP_ID="testserver")
|
||||
class LoginWebAuthnTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
@patch("hc.accounts.views.GetHelper.verify")
|
||||
def test_it_logs_in(self, mock_verify: Mock) -> None:
|
||||
mock_verify.return_value = True
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy-state"
|
||||
session.save()
|
||||
|
||||
r = self.client.post(self.url, {"response": "dummy response"})
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
self.assertNotIn("state", self.client.session)
|
||||
self.assertNotIn("2fa_user_id", self.client.session)
|
||||
|
||||
@patch("hc.accounts.views.GetHelper.verify")
|
||||
def test_it_redirects_after_login(self, mock_verify: Mock) -> None:
|
||||
mock_verify.return_value = True
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy-state"
|
||||
session.save()
|
||||
|
||||
url = self.url + "?next=" + self.channels_url
|
||||
r = self.client.post(url, {"response": "dummy response"})
|
||||
self.assertRedirects(r, self.channels_url)
|
||||
|
||||
def test_it_handles_bad_json(self) -> None:
|
||||
session = self.client.session
|
||||
session["state"] = "dummy-state"
|
||||
session.save()
|
||||
|
||||
r = self.client.post(self.url, {"response": "this is not json"})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@patch("hc.accounts.views.GetHelper.verify")
|
||||
def test_it_handles_authentication_failure(self, mock_verify: Mock) -> None:
|
||||
mock_verify.return_value = False
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy-state"
|
||||
session.save()
|
||||
|
||||
r = self.client.post(self.url, {"response": "this is not json"})
|
||||
self.assertEqual(r.status_code, 400)
|
|
@ -1,118 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta as td
|
||||
|
||||
from django.utils.timezone import now
|
||||
|
||||
from hc.api.models import Check
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class NotificationsTestCase(BaseTestCase):
|
||||
url = "/accounts/profile/notifications/"
|
||||
|
||||
def _payload(self, **kwargs: str) -> dict[str, str]:
|
||||
result = {"reports": "monthly", "nag_period": "0", "tz": "Europe/Riga"}
|
||||
result.update(kwargs)
|
||||
return result
|
||||
|
||||
def test_it_saves_reports_monthly(self) -> None:
|
||||
self.profile.reports = "off"
|
||||
def test_it_saves_reports_allowed_true(self):
|
||||
self.profile.reports_allowed = False
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.post(self.url, self._payload())
|
||||
form = {"reports_allowed": "on", "nag_period": "0"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.reports, "monthly")
|
||||
assert self.profile.next_report_date
|
||||
self.assertEqual(self.profile.next_report_date.day, 1)
|
||||
self.assertTrue(self.profile.reports_allowed)
|
||||
self.assertIsNotNone(self.profile.next_report_date)
|
||||
|
||||
def test_it_saves_reports_weekly(self) -> None:
|
||||
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")
|
||||
assert self.profile.next_report_date
|
||||
self.assertEqual(self.profile.next_report_date.weekday(), 0)
|
||||
|
||||
def test_it_saves_reports_off(self) -> None:
|
||||
self.profile.reports = "monthly"
|
||||
def test_it_saves_reports_allowed_false(self):
|
||||
self.profile.reports_allowed = True
|
||||
self.profile.next_report_date = now()
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.post(self.url, self._payload(reports="off"))
|
||||
form = {"nag_period": "0"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.reports, "off")
|
||||
self.assertFalse(self.profile.reports_allowed)
|
||||
self.assertIsNone(self.profile.next_report_date)
|
||||
|
||||
def test_it_sets_next_nag_date_when_setting_hourly_nag_period(self) -> None:
|
||||
Check.objects.create(project=self.project, status="down")
|
||||
|
||||
def test_it_saves_hourly_nag_period(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.post(self.url, self._payload(nag_period="3600"))
|
||||
form = {"nag_period": "3600"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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")
|
||||
|
||||
r = self.client.post(self.url, self._payload(nag_period="1234"))
|
||||
form = {"nag_period": "1234"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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")
|
||||
|
|
|
@ -1,148 +1,198 @@
|
|||
from __future__ import annotations
|
||||
from datetime import timedelta as td
|
||||
from django.core import mail
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from hc.accounts.models import Credential
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import now
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Member
|
||||
from hc.api.models import Check
|
||||
|
||||
|
||||
class ProfileTestCase(BaseTestCase):
|
||||
def test_it_shows_profile_page(self) -> None:
|
||||
|
||||
def test_it_sends_set_password_link(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Email and Password")
|
||||
self.assertContains(r, "Change Password")
|
||||
self.assertContains(r, "Set Up Authenticator App")
|
||||
|
||||
def test_leaving_works(self) -> None:
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
form = {"code": str(self.project.code), "leave_project": "1"}
|
||||
form = {"set_password": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertContains(r, "Left project <strong>Alices Project</strong>")
|
||||
self.assertNotContains(r, "Member")
|
||||
assert r.status_code == 302
|
||||
|
||||
# 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_keys": "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)
|
||||
self.assertFalse("b'" in api_key)
|
||||
|
||||
def test_it_revokes_api_key(self):
|
||||
self.profile.api_key_readonly = "R" * 32
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"revoke_api_keys": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
assert r.status_code == 200
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.api_key, "")
|
||||
self.assertEqual(self.profile.api_key_readonly, "")
|
||||
|
||||
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_skips_report_if_no_pings(self):
|
||||
check = Check(name="Test Check", user=self.alice)
|
||||
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(name="Test Check", user=self.alice)
|
||||
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(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.assertFalse(self.bob.memberships.exists())
|
||||
self.assertEqual(self.bobs_profile.current_team, None)
|
||||
|
||||
def test_leaving_checks_membership(self) -> None:
|
||||
self.client.login(username="charlie@example.org", password="password")
|
||||
def test_it_sets_team_name(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"code": str(self.project.code), "leave_project": "1"}
|
||||
form = {"set_team_name": "1", "team_name": "Alpha Team"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_it_shows_project_membership(self) -> None:
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.team_name, "Alpha Team")
|
||||
|
||||
def test_it_switches_to_own_team(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")
|
||||
self.client.get("/accounts/profile/")
|
||||
|
||||
def test_it_shows_readonly_project_membership(self) -> None:
|
||||
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) -> None:
|
||||
self.project.delete()
|
||||
# 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)
|
||||
|
||||
def test_it_sends_change_email_link(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "You do not have any projects. Create one!")
|
||||
form = {"change_email": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
assert r.status_code == 302
|
||||
|
||||
@override_settings(RP_ID=None)
|
||||
def test_it_hides_security_keys_bits_if_rp_id_not_set(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
# profile.token should be set now
|
||||
self.profile.refresh_from_db()
|
||||
token = self.profile.token
|
||||
self.assertTrue(len(token) > 10)
|
||||
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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")
|
||||
|
||||
# It should show a warning about Alices Key being the only second factor
|
||||
s = """The key "Alices Key" is currently your only second factor."""
|
||||
self.assertContains(r, s)
|
||||
|
||||
def test_it_handles_unusable_password(self) -> None:
|
||||
self.alice.set_unusable_password()
|
||||
self.alice.save()
|
||||
|
||||
# Authenticate using the ProfileBackend and a token:
|
||||
token = self.profile.prepare_token()
|
||||
self.client.login(username="alice", token=token)
|
||||
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Set Password")
|
||||
self.assertNotContains(r, "Change Password")
|
||||
|
||||
@override_settings(RP_ID="testserver")
|
||||
def test_it_shows_totp(self) -> None:
|
||||
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")
|
||||
|
||||
# It should show a warning about TOTP being the only second factor
|
||||
s = "The Authenticator app is currently your only second factor."
|
||||
self.assertContains(r, s)
|
||||
self.assertContains(r, "or register a Security Key to be used")
|
||||
|
||||
def test_it_shows_no_warning_if_multiple_keys_are_registered(self) -> None:
|
||||
Credential.objects.create(user=self.alice, name="Alices Key")
|
||||
Credential.objects.create(user=self.alice, name="Alices Other Key")
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get("/accounts/profile/")
|
||||
|
||||
self.assertNotContains(r, "is currently your only second factor.")
|
||||
|
||||
def test_it_shows_no_warning_if_key_and_totp_is_registered(self) -> None:
|
||||
Credential.objects.create(user=self.alice, name="Alices Key")
|
||||
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.assertNotContains(r, "is currently your only second factor.")
|
||||
|
||||
@override_settings(RP_ID=None)
|
||||
def test_it_does_not_mention_security_key_if_rp_id_is_not_set(self) -> None:
|
||||
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.assertNotContains(r, "or register a Security Key to be used")
|
||||
# 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)
|
||||
|
|
|
@ -1,126 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta as td
|
||||
from datetime import timezone
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.core import mail
|
||||
from django.core.mail import EmailMessage, EmailMultiAlternatives
|
||||
from django.utils.timezone import now
|
||||
|
||||
from hc.api.models import Check
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
CURRENT_TIME = datetime(2020, 1, 15, tzinfo=timezone.utc)
|
||||
MOCK_NOW = Mock(return_value=CURRENT_TIME)
|
||||
|
||||
|
||||
class ProfileModelTestCase(BaseTestCase):
|
||||
def get_html(self, email: EmailMessage) -> str:
|
||||
assert isinstance(email, EmailMultiAlternatives)
|
||||
html, _ = email.alternatives[0]
|
||||
assert isinstance(html, str)
|
||||
return html
|
||||
|
||||
@patch("hc.lib.date.now", MOCK_NOW)
|
||||
def test_it_sends_report(self) -> None:
|
||||
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 = self.get_html(message)
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
self.profile.next_nag_date = now()
|
||||
self.profile.update_next_nag_date()
|
||||
self.assertIsNone(self.profile.next_nag_date)
|
|
@ -1,411 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.core.mail import EmailMessage, EmailMultiAlternatives
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from hc.accounts.models import Member, Project
|
||||
from hc.api.models import TokenBucket
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class ProjectTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.url = "/projects/%s/settings/" % self.project.code
|
||||
|
||||
def get_html(self, email: EmailMessage) -> str:
|
||||
assert isinstance(email, EmailMultiAlternatives)
|
||||
html, _ = email.alternatives[0]
|
||||
assert isinstance(html, str)
|
||||
return html
|
||||
|
||||
def test_it_checks_access(self) -> None:
|
||||
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) -> None:
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Change Project Name")
|
||||
|
||||
def test_it_masks_keys_by_default(self) -> None:
|
||||
self.project.api_key_readonly = "R" * 32
|
||||
self.project.ping_key = "P" * 22
|
||||
self.project.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.assertNotContains(r, "X" * 32)
|
||||
self.assertNotContains(r, "R" * 32)
|
||||
self.assertNotContains(r, "P" * 22)
|
||||
|
||||
def test_it_shows_keys(self) -> None:
|
||||
self.project.api_key_readonly = "R" * 32
|
||||
self.project.ping_key = "P" * 22
|
||||
self.project.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"show_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, "P" * 22)
|
||||
self.assertContains(r, "Prometheus metrics endpoint")
|
||||
|
||||
def test_it_creates_readonly_key(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"create_key": "api_key_readonly"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(len(self.project.api_key_readonly), 32)
|
||||
self.assertFalse("b'" in self.project.api_key_readonly)
|
||||
|
||||
def test_it_requires_rw_access_to_create_key(self) -> None:
|
||||
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_key": "api_key_readonly"})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_revokes_api_key(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, {"revoke_key": "api_key"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(self.project.api_key, "")
|
||||
|
||||
def test_it_requires_rw_access_to_revoke_api_key(self) -> None:
|
||||
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_key": "api_key"})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_adds_team_member(self) -> None:
|
||||
# Use "'" in the name to see if does or doesn't get escaped in email subject:
|
||||
self.project.name = "Alice's Project"
|
||||
self.project.save()
|
||||
|
||||
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
|
||||
message = mail.outbox[0]
|
||||
subj = f"You have been invited to join Alice's Project on {settings.SITE_NAME}"
|
||||
self.assertEqual(message.subject, subj)
|
||||
|
||||
html = self.get_html(message)
|
||||
self.assertIn("You will be able to manage", message.body)
|
||||
self.assertIn("You will be able to manage", html)
|
||||
|
||||
@override_settings(EMAIL_HOST=None)
|
||||
def test_it_skips_invite_email_if_email_host_not_set(self) -> None:
|
||||
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)
|
||||
|
||||
self.assertEqual(self.project.member_set.count(), 2)
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_it_adds_readonly_team_member(self) -> None:
|
||||
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)
|
||||
|
||||
# And an email should have been sent
|
||||
message = mail.outbox[0]
|
||||
html = self.get_html(message)
|
||||
self.assertIn("You will be able to view", message.body)
|
||||
self.assertIn("You will be able to view", html)
|
||||
|
||||
def test_it_adds_manager_team_member(self) -> None:
|
||||
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) -> None:
|
||||
# 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:
|
||||
tq = TokenBucket.objects.filter(value="invite-%d" % self.alice.id)
|
||||
self.assertFalse(tq.exists())
|
||||
|
||||
def test_it_rejects_duplicate_membership(self) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
# 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) -> None:
|
||||
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) -> None:
|
||||
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_invites_user_with_email_as_username(self) -> None:
|
||||
User.objects.create(username="frank@example.org", email="frank@example.org")
|
||||
|
||||
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)
|
||||
|
||||
q = Member.objects.filter(project=self.project, user__email="frank@example.org")
|
||||
self.assertEqual(q.count(), 1)
|
||||
|
||||
def test_it_lets_owner_remove_team_member(self) -> None:
|
||||
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) -> None:
|
||||
# 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) -> None:
|
||||
# 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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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 Projects")
|
||||
self.assertContains(r, "bob@example.org")
|
||||
|
||||
def test_it_requires_rw_access_to_update_project_name(self) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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_keys": "1"})
|
||||
self.assertEqual(r.status_code, 403)
|
|
@ -1,51 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from hc.accounts.models import Member, Project
|
||||
from hc.api.models import Channel, Check
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class ProjectModelTestCase(BaseTestCase):
|
||||
def test_num_checks_available_handles_multiple_projects(self) -> None:
|
||||
# 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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
# 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) -> None:
|
||||
# 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) -> None:
|
||||
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) -> None:
|
||||
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())
|
|
@ -1,39 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta as td
|
||||
from unittest.mock import Mock
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.timezone import now
|
||||
|
||||
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
|
||||
|
||||
|
||||
class PruneUsersTestCase(BaseTestCase):
|
||||
year_ago = now() - td(days=365)
|
||||
year_ago = timezone.now() - timedelta(days=365)
|
||||
|
||||
def test_it_removes_old_never_logged_in_users(self) -> None:
|
||||
def test_it_removes_old_never_logged_in_users(self):
|
||||
self.charlie.date_joined = self.year_ago
|
||||
self.charlie.save()
|
||||
|
||||
# Charlie has one demo check
|
||||
charlies_project = Project.objects.create(owner=self.charlie)
|
||||
Check(project=charlies_project).save()
|
||||
Check(user=self.charlie).save()
|
||||
|
||||
Command(stdout=Mock()).handle()
|
||||
Command().handle()
|
||||
|
||||
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
|
||||
self.assertEqual(Check.objects.count(), 0)
|
||||
|
||||
def test_it_leaves_team_members_alone(self) -> None:
|
||||
|
||||
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(stdout=Mock()).handle()
|
||||
|
||||
Command().handle()
|
||||
# Bob belongs to a team so should not get removed
|
||||
self.assertEqual(User.objects.filter(username="bob").count(), 1)
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||
self.assertContains(r, "alice@example.org")
|
||||
|
||||
def test_it_forces_lowercase(self) -> None:
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="USER@example.org")
|
||||
# It should have created a new user account, the email should have been
|
||||
# converted to lowercase
|
||||
self.assertContains(r, "user@example.org")
|
||||
|
||||
def test_it_does_nothing_when_header_not_set(self) -> None:
|
||||
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) -> None:
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="")
|
||||
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||
|
||||
def test_it_creates_user(self) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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")
|
||||
|
||||
mock_auth.authenticate.assert_not_called()
|
||||
self.assertContains(r, "alice@example.org")
|
|
@ -1,65 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from hc.accounts.models import Credential
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
@override_settings(RP_ID="testserver")
|
||||
class RemoveCredentialTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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) -> None:
|
||||
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)
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue