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

|
||||
[](https://travis-ci.org/healthchecks/healthchecks)
|
||||
[](https://coveralls.io/github/healthchecks/healthchecks?branch=master)
|
||||
|
||||
|
||||
@ -14,133 +14,161 @@
|
||||
|
||||

|
||||
|
||||
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 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 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.
|
||||
It is live here: [http://healthchecks.io/](http://healthchecks.io/)
|
||||
|
||||
The building blocks are:
|
||||
|
||||
* Python 3.6+
|
||||
* Django 3
|
||||
* Python 2 or Python 3
|
||||
* Django 1.11
|
||||
* PostgreSQL or MySQL
|
||||
|
||||
Healthchecks is licensed under the BSD 3-clause license.
|
||||
|
||||
Healthchecks is available as a hosted service
|
||||
at [https://healthchecks.io/](https://healthchecks.io/).
|
||||
|
||||
## Setting Up for Development
|
||||
|
||||
To set up Healthchecks development environment:
|
||||
These are instructions for setting up healthchecks Django app
|
||||
in development environment.
|
||||
|
||||
* Install dependencies (Debian/Ubuntu):
|
||||
|
||||
$ sudo apt-get update
|
||||
$ sudo apt-get install -y gcc python3-dev python3-venv libpq-dev
|
||||
|
||||
* Prepare directory for project code and virtualenv. Feel free to use a
|
||||
different location:
|
||||
* prepare directory for project code and virtualenv:
|
||||
|
||||
$ mkdir -p ~/webapps
|
||||
$ cd ~/webapps
|
||||
|
||||
* Prepare virtual environment
|
||||
* prepare virtual environment
|
||||
(with virtualenv you get pip, we'll use it soon to install requirements):
|
||||
|
||||
$ python3 -m venv hc-venv
|
||||
$ virtualenv --python=python3 hc-venv
|
||||
$ source hc-venv/bin/activate
|
||||
$ pip3 install wheel # make sure wheel is installed in the venv
|
||||
|
||||
* Check out project code:
|
||||
* check out project code:
|
||||
|
||||
$ git clone https://github.com/healthchecks/healthchecks.git
|
||||
|
||||
* Install requirements (Django, ...) into virtualenv:
|
||||
* install requirements (Django, ...) into virtualenv:
|
||||
|
||||
$ pip install -r healthchecks/requirements.txt
|
||||
|
||||
* healthchecks is configured to use a SQLite database by default. To use
|
||||
PostgreSQL or MySQL database, create and edit `hc/local_settings.py` file.
|
||||
There is a template you can copy and edit as needed:
|
||||
|
||||
* Create database tables and a superuser account:
|
||||
$ cd ~/webapps/healthchecks
|
||||
$ cp hc/local_settings.py.example hc/local_settings.py
|
||||
|
||||
* create database tables and the superuser account:
|
||||
|
||||
$ 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`).
|
||||
|
||||
To use PostgreSQL or MySQL, see the section **Database Configuration** section
|
||||
below.
|
||||
|
||||
* Run tests:
|
||||
|
||||
$ ./manage.py test
|
||||
|
||||
* Run development server:
|
||||
* run development server:
|
||||
|
||||
$ ./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.
|
||||
Site configuration is kept in `hc/settings.py`. Additional configuration
|
||||
is loaded from `hc/local_settings.py` file, if it exists. You
|
||||
can create this file (should be right next to `settings.py` in the filesystem)
|
||||
and override settings as needed.
|
||||
|
||||
[Full list of configuration parameters](https://healthchecks.io/docs/self_hosted_configuration/).
|
||||
Some useful settings keys to override are:
|
||||
|
||||
## Accessing Administration Panel
|
||||
`SITE_ROOT` is used to build fully qualified URLs for pings, and for use in
|
||||
emails and notifications. Example:
|
||||
|
||||
Healthchecks comes with Django's administration panel where you can manually
|
||||
view and modify user accounts, projects, checks, integrations etc. To access it,
|
||||
```python
|
||||
SITE_ROOT = "https://my-monitoring-project.com"
|
||||
```
|
||||
|
||||
* 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"
|
||||
`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 stored in `hc/settings.py` and can be overriden
|
||||
in `hc/local_settings.py`. The default database engine is SQLite. To use
|
||||
PostgreSQL, create `hc/local_settings.py` if it does not exist, and put the
|
||||
following in it, changing it as neccessary:
|
||||
|
||||
```python
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'your-database-name-here',
|
||||
'USER': 'your-database-user-here',
|
||||
'PASSWORD': 'your-database-password-here',
|
||||
'TEST': {'CHARSET': 'UTF8'}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For MySQL:
|
||||
|
||||
```python
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.mysql',
|
||||
'NAME': 'your-database-name-here',
|
||||
'USER': 'your-database-user-here',
|
||||
'PASSWORD': 'your-database-password-here',
|
||||
'TEST': {'CHARSET': 'UTF8'}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also use `hc/local_settings.py` to read database
|
||||
configuration from environment variables like so:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': os.environ['DB_ENGINE'],
|
||||
'NAME': os.environ['DB_NAME'],
|
||||
'USER': os.environ['DB_USER'],
|
||||
'PASSWORD': os.environ['DB_PASSWORD'],
|
||||
'TEST': {'CHARSET': 'UTF8'}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sending Emails
|
||||
|
||||
Healthchecks must be able to send email messages, so it can send out login
|
||||
links and alerts to users. 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:
|
||||
|
||||
```python
|
||||
EMAIL_HOST = "your-smtp-server-here.com"
|
||||
EMAIL_PORT = 587
|
||||
EMAIL_HOST_USER = "smtp-username"
|
||||
EMAIL_HOST_PASSWORD = "smtp-password"
|
||||
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/1.10/topics/email/) section.
|
||||
|
||||
## Receiving Emails
|
||||
|
||||
Healthchecks comes with a `smtpd` management command, which starts up a
|
||||
SMTP listener service. With the command running, you can ping your
|
||||
checks by sending email messages
|
||||
to `your-uuid-here@my-monitoring-project.com` email addresses.
|
||||
|
||||
Start the SMTP listener on port 2525:
|
||||
|
||||
$ ./manage.py smtpd --port 2525
|
||||
|
||||
Send a test email:
|
||||
|
||||
$ curl --url 'smtp://127.0.0.1:2525' \
|
||||
--mail-from 'foo@example.org' \
|
||||
--mail-rcpt '11111111-1111-1111-1111-111111111111@my-monitoring-project.com' \
|
||||
-F '='
|
||||
|
||||
|
||||
|
||||
## Sending Status Notifications
|
||||
|
||||
healtchecks comes with a `sendalerts` management command, which continuously
|
||||
@ -155,7 +183,7 @@ manager like [supervisor](http://supervisord.org/) or systemd.
|
||||
|
||||
## Database Cleanup
|
||||
|
||||
With time and use the Healthchecks database will grow in size. You may
|
||||
With time and use the healthchecks database will grow in size. You may
|
||||
decide to prune old data: inactive user accounts, old checks not assigned
|
||||
to users, records of outgoing email messages and records of received pings.
|
||||
There are separate Django management commands for each task:
|
||||
@ -167,10 +195,13 @@ There are separate Django management commands for each task:
|
||||
$ ./manage.py prunepings
|
||||
```
|
||||
|
||||
Note: 100 is the default value but you can configure a different
|
||||
limit per-user. To do that, go to the
|
||||
Administration Panel, look up user's **Profile** and modify its
|
||||
"Ping log limit" field.
|
||||
* Remove checks older than 2 hours that are not assigned to users. Such
|
||||
checks are by-products of random visitors and robots loading the welcome
|
||||
page and never setting up an account:
|
||||
|
||||
```
|
||||
$ ./manage.py prunechecks
|
||||
```
|
||||
|
||||
* Remove old records of sent notifications. For each check, remove
|
||||
notifications that are older than the oldest stored ping for same check.
|
||||
@ -180,9 +211,9 @@ There are separate Django management commands for each task:
|
||||
```
|
||||
|
||||
* Remove user accounts that match either of these conditions:
|
||||
* Account was created more than 6 months ago, and user has never logged in.
|
||||
* Account was created more than 6 months ago, and user has never logged in.
|
||||
These can happen when user enters invalid email address when signing up.
|
||||
* Last login was more than 6 months ago, and the account has no checks.
|
||||
* Last login was more than 6 months ago, and the account has no checks.
|
||||
Assume the user doesn't intend to use the account any more and would
|
||||
probably *want* it removed.
|
||||
|
||||
@ -190,80 +221,13 @@ There are separate Django management commands for each task:
|
||||
$ ./manage.py pruneusers
|
||||
```
|
||||
|
||||
* Remove old records from the `api_tokenbucket` table. The TokenBucket
|
||||
model is used for rate-limiting login attempts and similar operations.
|
||||
Any records older than one day can be safely removed.
|
||||
|
||||
```
|
||||
$ ./manage.py prunetokenbucket
|
||||
```
|
||||
|
||||
* Remove old records from the `api_flip` table. The Flip
|
||||
objects are used to track status changes of checks, and to calculate
|
||||
downtime statistics month by month. Flip objects from more than 3 months
|
||||
ago are not used and can be safely removed.
|
||||
|
||||
```
|
||||
$ ./manage.py pruneflips
|
||||
```
|
||||
|
||||
When you first try these commands on your data, it is a good idea to
|
||||
test them on a copy of your database, not on the live database right away.
|
||||
In a production setup, you should also have regular, automated database
|
||||
backups set up.
|
||||
|
||||
## Two-factor Authentication
|
||||
|
||||
Healthchecks optionally supports two-factor authentication using the WebAuthn
|
||||
standard. To enable WebAuthn support, set the `RP_ID` (relying party identifier )
|
||||
setting to a non-null value. Set its value to your site's domain without scheme
|
||||
and without port. For example, if your site runs on `https://my-hc.example.org`,
|
||||
set `RP_ID` to `my-hc.example.org`.
|
||||
|
||||
Note that WebAuthn requires HTTPS, even if running on localhost. To test WebAuthn
|
||||
locally with a self-signed certificate, you can use the `runsslserver` command
|
||||
from the `django-sslserver` package.
|
||||
|
||||
## External Authentication
|
||||
|
||||
Healthchecks supports external authentication by means of HTTP headers set by
|
||||
reverse proxies or the WSGI server. This allows you to integrate it into your
|
||||
existing authentication system (e.g., LDAP or OAuth) via an authenticating proxy.
|
||||
When this option is enabled, **healtchecks will trust the header's value implicitly**,
|
||||
so it is **very important** to ensure that attackers cannot set the value themselves
|
||||
(and thus impersonate any user). How to do this varies by your chosen proxy,
|
||||
but generally involves configuring it to strip out headers that normalize to the
|
||||
same name as the chosen identity header.
|
||||
|
||||
To enable this feature, set the `REMOTE_USER_HEADER` value to a header you wish to
|
||||
authenticate with. HTTP headers will be prefixed with `HTTP_` and have any dashes
|
||||
converted to underscores. Headers without that prefix can be set by the WSGI server
|
||||
itself only, which is more secure.
|
||||
|
||||
When `REMOTE_USER_HEADER` is set, Healthchecks will:
|
||||
- assume the header contains user's email address
|
||||
- look up and automatically log in the user with a matching email address
|
||||
- automatically create an user account if it does not exist
|
||||
- disable the default authentication methods (login link to email, password)
|
||||
|
||||
## Integrations
|
||||
|
||||
### Slack
|
||||
|
||||
To enable the Slack "self-service" integration, you will need to create a "Slack App".
|
||||
|
||||
To do so:
|
||||
* Create a _new Slack app_ on https://api.slack.com/apps/
|
||||
* Add at least _one scope_ in the permissions section to be able to deploy the app in your workspace (By example `incoming-webhook` for the `Bot Token Scopes`
|
||||
https://api.slack.com/apps/APP_ID/oauth?).
|
||||
* Add a _redirect url_ in the format `SITE_ROOT/integrations/add_slack_btn/`.
|
||||
For example, if your SITE_ROOT is `https://my-hc.example.org` then the redirect URL would be
|
||||
`https://my-hc.example.org/integrations/add_slack_btn/`.
|
||||
* Look up your Slack app for the Client ID and Client Secret at https://api.slack.com/apps/APP_ID/general? . Put them
|
||||
in `SLACK_CLIENT_ID` and `SLACK_CLIENT_SECRET` environment
|
||||
variables.
|
||||
|
||||
|
||||
### Discord
|
||||
|
||||
To enable Discord integration, you will need to:
|
||||
@ -273,55 +237,29 @@ To enable Discord integration, you will need to:
|
||||
`SITE_ROOT/integrations/add_discord/`. For example, if you are running a
|
||||
development server on `localhost:8000` then the redirect URI would be
|
||||
`http://localhost:8000/integrations/add_discord/`
|
||||
* Look up your Discord app's Client ID and Client Secret. Put them
|
||||
in `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET` environment
|
||||
variables.
|
||||
* Look up your Discord app's Client ID and Client Secret. Add them
|
||||
to your `hc/local_settings.py` file as `DISCORD_CLIENT_ID` and
|
||||
`DISCORD_CLIENT_SECRET` fields.
|
||||
|
||||
|
||||
### 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:
|
||||
|
||||
* On Healthchecks, the user adds a "Pushover" integration to a project
|
||||
* Healthchecks redirects user's browser to a Pushover.net subscription page
|
||||
* User approves adding the Healthchecks subscription to their Pushover account
|
||||
* Pushover.net HTTP redirects back to Healthchecks with a subscription token
|
||||
* Healthchecks saves the subscription token and uses it for sending Pushover
|
||||
notifications
|
||||
|
||||
To enable the Pushover integration, you will need to:
|
||||
|
||||
* Register a new application on Pushover via https://pushover.net/apps/build.
|
||||
* Within the Pushover 'application' configuration, enable subscriptions.
|
||||
Make sure the subscription type is set to "URL". Also make sure the redirect
|
||||
URL is configured to point back to the root of the Healthchecks instance
|
||||
(e.g., `http://healthchecks.example.com/`).
|
||||
* Put the Pushover application API Token and the Pushover subscription URL in
|
||||
`PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL` environment
|
||||
variables. The Pushover subscription URL should look similar to
|
||||
`https://pushover.net/subscribe/yourAppName-randomAlphaNumericData`.
|
||||
|
||||
### Signal
|
||||
|
||||
Healthchecks uses [signal-cli](https://github.com/AsamK/signal-cli) to send Signal
|
||||
notifications. Healthcecks interacts with signal-cli over DBus.
|
||||
|
||||
To enable the Signal integration:
|
||||
|
||||
* Set up and configure signal-cli to listen on DBus system bus ([instructions](https://github.com/AsamK/signal-cli/wiki/DBus-service)).
|
||||
Make sure you can send test messages from command line, using the `dbus-send`
|
||||
example given in the signal-cli instructions.
|
||||
* Set the `SIGNAL_CLI_ENABLED` environment variable to `True`.
|
||||
To enable Pushover integration, you will need to:
|
||||
|
||||
* register a new application on https://pushover.net/apps/build
|
||||
* enable subscriptions in your application and make sure to enable the URL
|
||||
subscription type
|
||||
* add the application token and subscription URL to `hc/local_settings.py`, as
|
||||
`PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL`
|
||||
|
||||
### Telegram
|
||||
|
||||
* Create a Telegram bot by talking to the
|
||||
[BotFather](https://core.telegram.org/bots#6-botfather). Set the bot's name,
|
||||
description, user picture, and add a "/start" command.
|
||||
* After creating the bot you will have the bot's name and token. Put them
|
||||
in `TELEGRAM_BOT_NAME` and `TELEGRAM_TOKEN` environment variables.
|
||||
* After creating the bot you will have the bot's name and token. Add them
|
||||
to your `hc/local_settings.py` file as `TELEGRAM_BOT_NAME` and
|
||||
`TELEGRAM_TOKEN` fields.
|
||||
* 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:
|
||||
@ -333,92 +271,3 @@ where to forward channel messages by invoking Telegram's
|
||||
|
||||
For this to work, your `SITE_ROOT` needs to be correct and use "https://"
|
||||
scheme.
|
||||
|
||||
### Apprise
|
||||
|
||||
To enable Apprise integration, you will need to:
|
||||
|
||||
* ensure you have apprise installed in your local environment:
|
||||
```bash
|
||||
pip install apprise
|
||||
```
|
||||
* enable the apprise functionality by setting the `APPRISE_ENABLED` environment variable.
|
||||
|
||||
### Shell Commands
|
||||
|
||||
The "Shell Commands" integration runs user-defined local shell commands when checks
|
||||
go up or down. This integration is disabled by default, and can be enabled by setting
|
||||
the `SHELL_ENABLED` environment variable to `True`.
|
||||
|
||||
Note: be careful when using "Shell Commands" integration, and only enable it when
|
||||
you fully trust the users of your Healthchecks instance. The commands will be executed
|
||||
by the `manage.py sendalerts` process, and will run with the same system permissions as
|
||||
the `sendalerts` process.
|
||||
|
||||
### Matrix
|
||||
|
||||
To enable the Matrix integration you will need to:
|
||||
|
||||
* Register a bot user (for posting notifications) in your preferred homeserver.
|
||||
* Use the [Login API call](https://www.matrix.org/docs/guides/client-server-api#login)
|
||||
to retrieve bot user's access token. You can run it as shown in the documentation,
|
||||
using curl in command shell.
|
||||
* Set the `MATRIX_` environment variables. Example:
|
||||
|
||||
```
|
||||
MATRIX_HOMESERVER=https://matrix.org
|
||||
MATRIX_USER_ID=@mychecks:matrix.org
|
||||
MATRIX_ACCESS_TOKEN=[a long string of characters returned by the login call]
|
||||
```
|
||||
|
||||
### PagerDuty Simple Install Flow
|
||||
|
||||
To enable PagerDuty [Simple Install Flow](https://developer.pagerduty.com/docs/app-integration-development/events-integration/),
|
||||
|
||||
* Register a PagerDuty app at [PagerDuty](https://pagerduty.com/) › Developer Mode › My Apps
|
||||
* In the newly created app, add the "Events Integration" functionality
|
||||
* Specify a Redirect URL: `https://your-domain.com/integrations/add_pagerduty/`
|
||||
* Copy the displayed app_id value (PXXXXX) and put it in the `PD_APP_ID` environment
|
||||
variable
|
||||
|
||||
## Running in Production
|
||||
|
||||
Here is a non-exhaustive list of pointers and things to check before launching a Healthchecks instance
|
||||
in production.
|
||||
|
||||
* Environment variables, settings.py and local_settings.py.
|
||||
* [DEBUG](https://docs.djangoproject.com/en/2.2/ref/settings/#debug). Make sure it is
|
||||
set to `False`.
|
||||
* [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.2/ref/settings/#allowed-hosts).
|
||||
Make sure it contains the correct domain name you want to use.
|
||||
* Server Errors. When DEBUG=False, Django will not show detailed error pages, and
|
||||
will not print exception tracebacks to standard output. To receive exception
|
||||
tracebacks in email, review and edit the
|
||||
[ADMINS](https://docs.djangoproject.com/en/2.2/ref/settings/#admins) and
|
||||
[SERVER_EMAIL](https://docs.djangoproject.com/en/2.2/ref/settings/#server-email)
|
||||
settings. Consider setting up exception logging with [Sentry](https://sentry.io/for/django/).
|
||||
* Management commands that need to be run during each deployment.
|
||||
* `manage.py compress` – creates combined JS and CSS bundles and
|
||||
places them in the `static-collected` directory.
|
||||
* `manage.py collectstatic` – collects static files in the `static-collected`
|
||||
directory.
|
||||
* `manage.py migrate` – applies any pending database schema changes
|
||||
and data migrations.
|
||||
* Processes that need to be running constantly.
|
||||
* `manage.py runserver` is intended for development only.
|
||||
**Do not use it in production**, instead consider using
|
||||
[uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) or
|
||||
[gunicorn](https://gunicorn.org/).
|
||||
* `manage.py sendalerts` is the process that monitors checks and sends out
|
||||
monitoring alerts. It must be always running, it must be started on reboot, and it
|
||||
must be restarted if it itself crashes. On modern linux systems, a good option is
|
||||
to [define a systemd service](https://github.com/healthchecks/healthchecks/issues/273#issuecomment-520560304)
|
||||
for it.
|
||||
* Static files. Healthchecks serves static files on its own, no configuration
|
||||
required. It uses the [Whitenoise library](http://whitenoise.evans.io/en/stable/index.html)
|
||||
for this.
|
||||
* General
|
||||
* Make sure the database is secured well and is getting backed up regularly
|
||||
* Make sure the TLS certificates are secured well and are getting refreshed regularly
|
||||
* Have monitoring in place to be sure the Healthchecks instance itself is operational
|
||||
(is accepting pings, is sending out alerts, is not running out of resources).
|
||||
|
67
docker/.env
67
docker/.env
@ -1,67 +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
|
||||
LINENOTIFY_CLIENT_ID=
|
||||
LINENOTIFY_CLIENT_SECRET=
|
||||
MASTER_BADGE_LABEL=Mychecks
|
||||
MATRIX_ACCESS_TOKEN=
|
||||
MATRIX_HOMESERVER=
|
||||
MATRIX_USER_ID=
|
||||
MATTERMOST_ENABLED=True
|
||||
MSTEAMS_ENABLED=True
|
||||
OPSGENIE_ENABLED=True
|
||||
PAGERTREE_ENABLED=True
|
||||
PD_APP_ID=
|
||||
PD_ENABLED=True
|
||||
PD_VENDOR_KEY=
|
||||
PING_BODY_LIMIT=10000
|
||||
PING_EMAIL_DOMAIN=localhost
|
||||
PING_ENDPOINT=http://localhost:8000/ping/
|
||||
PROMETHEUS_ENABLED=True
|
||||
PUSHBULLET_CLIENT_ID=
|
||||
PUSHBULLET_CLIENT_SECRET=
|
||||
PUSHOVER_API_TOKEN=
|
||||
PUSHOVER_EMERGENCY_EXPIRATION=86400
|
||||
PUSHOVER_EMERGENCY_RETRY_DELAY=300
|
||||
PUSHOVER_SUBSCRIPTION_URL=
|
||||
REGISTRATION_OPEN=True
|
||||
REMOTE_USER_HEADER=
|
||||
RP_ID=
|
||||
SECRET_KEY=---
|
||||
SHELL_ENABLED=False
|
||||
SIGNAL_CLI_ENABLED=False
|
||||
SITE_NAME=Mychecks
|
||||
SITE_ROOT=http://localhost:8000
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
SLACK_ENABLED=True
|
||||
SPIKE_ENABLED=True
|
||||
TELEGRAM_BOT_NAME=ExampleBot
|
||||
TELEGRAM_TOKEN=
|
||||
TRELLO_APP_KEY=
|
||||
TWILIO_ACCOUNT=
|
||||
TWILIO_AUTH=
|
||||
TWILIO_FROM=
|
||||
TWILIO_USE_WHATSAPP=False
|
||||
USE_PAYMENTS=False
|
||||
VICTOROPS_ENABLED=True
|
||||
WEBHOOKS_ENABLED=True
|
||||
ZULIP_ENABLED=True
|
@ -1,23 +0,0 @@
|
||||
FROM python:3.9
|
||||
|
||||
RUN useradd --system hc
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1
|
||||
WORKDIR /opt/healthchecks
|
||||
|
||||
COPY requirements.txt /tmp
|
||||
RUN \
|
||||
pip install --no-cache-dir -r /tmp/requirements.txt && \
|
||||
pip install uwsgi
|
||||
|
||||
COPY . /opt/healthchecks/
|
||||
|
||||
RUN \
|
||||
rm -f /opt/healthchecks/hc/local_settings.py && \
|
||||
DEBUG=False SECRET_KEY=build-key ./manage.py collectstatic --noinput && \
|
||||
DEBUG=False SECRET_KEY=build-key ./manage.py compress
|
||||
|
||||
USER hc
|
||||
|
||||
CMD [ "uwsgi", "/opt/healthchecks/docker/uwsgi.ini"]
|
||||
|
@ -1,32 +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: The Docker configuration is a recent addition, and, for the time being,
|
||||
should be considered as highly experimental**.
|
||||
|
||||
Note: For the sake of simplicity, the sample configuration starts a single database
|
||||
node and a single web server node, both on the same host. It also does not handle SSL
|
||||
termination. If you plan to expose it to the public internet, make sure you put a
|
||||
SSL-terminating load balancer or reverse proxy in front of it.
|
||||
|
||||
## Getting Started
|
||||
|
||||
* Add your configuration in the `/docker/.env` file.
|
||||
As a minimum, set the following fields:
|
||||
* `DEFAULT_FROM_EMAIL` – the "From:" address for outbound emails
|
||||
* `EMAIL_HOST` – the SMTP server
|
||||
* `EMAIL_HOST_PASSWORD` – the SMTP password
|
||||
* `EMAIL_HOST_USER` – the SMTP username
|
||||
* `SECRET_KEY` – secures HTTP sessions, set to a random value
|
||||
* Create and start containers:
|
||||
|
||||
$ docker-compose up
|
||||
|
||||
* Create a superuser:
|
||||
|
||||
$ docker-compose run web /opt/healthchecks/manage.py createsuperuser
|
||||
|
||||
* Open [http://localhost:8000](http://localhost:8000) in your browser and log in with
|
||||
the credentials from the previous step.
|
@ -1,24 +0,0 @@
|
||||
version: "3"
|
||||
volumes:
|
||||
db-data:
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:12
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql
|
||||
environment:
|
||||
- POSTGRES_DB=$DB_NAME
|
||||
- POSTGRES_PASSWORD=$DB_PASSWORD
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 8000:8000
|
||||
depends_on:
|
||||
- db
|
||||
command: bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; uwsgi /opt/healthchecks/docker/uwsgi.ini'
|
||||
|
@ -1,17 +0,0 @@
|
||||
[uwsgi]
|
||||
master
|
||||
die-on-term
|
||||
http-socket = :8000
|
||||
harakiri = 10
|
||||
post-buffering = 4096
|
||||
processes = 4
|
||||
enable-threads
|
||||
threads = 1
|
||||
chdir = /opt/healthchecks
|
||||
module = hc.wsgi:application
|
||||
thunder-lock
|
||||
disable-write-exception
|
||||
|
||||
hook-pre-app = exec:./manage.py migrate
|
||||
attach-daemon = ./manage.py sendalerts
|
||||
attach-daemon = ./manage.py sendreports --loop
|
@ -1,35 +1,12 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import login as auth_login
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Count, F
|
||||
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 escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from hc.accounts.models import Credential, Profile, Project
|
||||
|
||||
|
||||
@mark_safe
|
||||
def _format_usage(num_checks, num_channels):
|
||||
result = ""
|
||||
|
||||
if num_checks == 0:
|
||||
result += "0 checks, "
|
||||
elif num_checks == 1:
|
||||
result += "1 check, "
|
||||
else:
|
||||
result += f"<strong>{num_checks} checks</strong>, "
|
||||
|
||||
if num_channels == 0:
|
||||
result += "0 channels"
|
||||
elif num_channels == 1:
|
||||
result += "1 channel"
|
||||
else:
|
||||
result += f"<strong>{num_channels} channels</strong>"
|
||||
|
||||
return result
|
||||
from hc.accounts.models import Profile
|
||||
from hc.api.models import Channel, Check
|
||||
|
||||
|
||||
class Fieldset:
|
||||
@ -43,200 +20,110 @@ class Fieldset:
|
||||
|
||||
class ProfileFieldset(Fieldset):
|
||||
name = "User Profile"
|
||||
fields = (
|
||||
"email",
|
||||
"reports",
|
||||
"tz",
|
||||
"theme",
|
||||
"next_report_date",
|
||||
"nag_period",
|
||||
"next_nag_date",
|
||||
"deletion_notice_date",
|
||||
"token",
|
||||
"sort",
|
||||
)
|
||||
fields = ("email", "api_key", "current_team", "reports_allowed",
|
||||
"next_report_date", "nag_period", "next_nag_date",
|
||||
"token", "sort")
|
||||
|
||||
|
||||
class TeamFieldset(Fieldset):
|
||||
name = "Team"
|
||||
fields = (
|
||||
"team_limit",
|
||||
"check_limit",
|
||||
"ping_log_limit",
|
||||
"sms_limit",
|
||||
"sms_sent",
|
||||
"last_sms_date",
|
||||
"call_limit",
|
||||
"calls_sent",
|
||||
"last_call_date",
|
||||
)
|
||||
|
||||
|
||||
class NumChecksFilter(admin.SimpleListFilter):
|
||||
title = "check count"
|
||||
|
||||
parameter_name = "num_checks"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
("10", "more than 10"),
|
||||
("20", "more than 20"),
|
||||
("50", "more than 50"),
|
||||
("100", "more than 100"),
|
||||
("500", "more than 500"),
|
||||
("1000", "more than 1000"),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if not self.value():
|
||||
return
|
||||
|
||||
value = int(self.value())
|
||||
return queryset.filter(num_checks__gt=value)
|
||||
fields = ("team_name", "team_limit", "check_limit",
|
||||
"ping_log_limit", "sms_limit", "sms_sent", "last_sms_date",
|
||||
"bill_to")
|
||||
|
||||
|
||||
@admin.register(Profile)
|
||||
class ProfileAdmin(admin.ModelAdmin):
|
||||
|
||||
class Media:
|
||||
css = {"all": ("css/admin/profiles.css",)}
|
||||
css = {
|
||||
'all': ('css/admin/profiles.css',)
|
||||
}
|
||||
|
||||
readonly_fields = ("user", "email")
|
||||
raw_id_fields = ("current_team", )
|
||||
list_select_related = ("user", )
|
||||
list_display = ("id", "users", "checks", "invited",
|
||||
"reports_allowed", "ping_log_limit", "sms")
|
||||
search_fields = ["id", "user__email"]
|
||||
list_per_page = 30
|
||||
list_select_related = ("user",)
|
||||
list_display = (
|
||||
"id",
|
||||
"email",
|
||||
"checks",
|
||||
"date_joined",
|
||||
"last_active_date",
|
||||
"projects",
|
||||
"invited",
|
||||
"sms",
|
||||
"reports",
|
||||
)
|
||||
list_filter = (
|
||||
"user__date_joined",
|
||||
"last_active_date",
|
||||
"reports",
|
||||
"check_limit",
|
||||
NumChecksFilter,
|
||||
"theme",
|
||||
)
|
||||
actions = ("login",)
|
||||
list_filter = ("team_limit", "reports_allowed",
|
||||
"check_limit", "next_report_date")
|
||||
|
||||
fieldsets = (ProfileFieldset.tuple(), TeamFieldset.tuple())
|
||||
|
||||
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
|
||||
|
||||
@mark_safe
|
||||
def email(self, obj):
|
||||
s = escape(obj.user.email)
|
||||
if obj.plan:
|
||||
s = "%s <span>%s</span>" % (s, obj.plan)
|
||||
|
||||
return s
|
||||
|
||||
def date_joined(self, obj):
|
||||
return obj.user.date_joined
|
||||
|
||||
@mark_safe
|
||||
def projects(self, obj):
|
||||
return render_to_string("admin/profile_list_projects.html", {"profile": obj})
|
||||
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):
|
||||
s = "%d of %d" % (obj.num_checks, obj.check_limit)
|
||||
if obj.num_checks > 1:
|
||||
s = "<b>%s</b>" % s
|
||||
return s
|
||||
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.num_members, obj.team_limit)
|
||||
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 login(self, request, qs):
|
||||
profile = qs.get()
|
||||
auth_login(request, profile.user, "hc.accounts.backends.EmailBackend")
|
||||
return redirect("hc-index")
|
||||
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ("code", "owner")
|
||||
list_select_related = ("owner",)
|
||||
list_display = ("id", "name_", "users", "usage", "switch")
|
||||
search_fields = ["id", "name", "owner__email"]
|
||||
|
||||
class Media:
|
||||
css = {"all": ("css/admin/projects.css",)}
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(ProjectAdmin, self).get_queryset(request)
|
||||
qs = qs.annotate(num_channels=Count("channel", distinct=True))
|
||||
qs = qs.annotate(num_checks=Count("check", distinct=True))
|
||||
qs = qs.annotate(num_members=Count("member", distinct=True))
|
||||
return qs
|
||||
|
||||
def name_(self, obj):
|
||||
if obj.name:
|
||||
return obj.name
|
||||
|
||||
return "Default Project for %s" % obj.owner.email
|
||||
|
||||
@mark_safe
|
||||
def users(self, obj):
|
||||
if obj.num_members == 0:
|
||||
return obj.owner.email
|
||||
else:
|
||||
return render_to_string("admin/project_list_team.html", {"project": obj})
|
||||
|
||||
def email(self, obj):
|
||||
return obj.owner.email
|
||||
|
||||
def usage(self, obj):
|
||||
return _format_usage(obj.num_checks, obj.num_channels)
|
||||
|
||||
@mark_safe
|
||||
def switch(self, obj):
|
||||
url = reverse("hc-checks", args=[obj.code])
|
||||
return "<a href='%s'>Show Checks</a>" % url
|
||||
return obj.user.email
|
||||
|
||||
|
||||
class HcUserAdmin(UserAdmin):
|
||||
actions = ["send_report", "send_nag"]
|
||||
list_display = (
|
||||
"id",
|
||||
"email",
|
||||
"usage",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"is_staff",
|
||||
)
|
||||
actions = ["send_report"]
|
||||
list_display = ('id', 'email', 'date_joined', 'engagement',
|
||||
'is_staff', 'checks')
|
||||
|
||||
list_display_links = ("id", "email")
|
||||
list_filter = ("last_login", "date_joined", "is_staff", "is_active")
|
||||
|
||||
ordering = ["-id"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(num_checks=Count("project__check", distinct=True))
|
||||
qs = qs.annotate(num_channels=Count("project__channel", distinct=True))
|
||||
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
|
||||
|
||||
@mark_safe
|
||||
def usage(self, user):
|
||||
return _format_usage(user.num_checks, user.num_channels)
|
||||
if num_channels == 0:
|
||||
result += "0 channels"
|
||||
elif num_channels == 1:
|
||||
result += "1 channel, "
|
||||
else:
|
||||
result += "<strong>%d channels</strong>, " % num_channels
|
||||
|
||||
return result
|
||||
|
||||
engagement.allow_tags = True
|
||||
|
||||
def checks(self, user):
|
||||
url = reverse("hc-switch-team", args=[user.username])
|
||||
return "<a href='%s'>Checks</a>" % url
|
||||
|
||||
checks.allow_tags = True
|
||||
|
||||
def send_report(self, request, qs):
|
||||
for user in qs:
|
||||
@ -244,22 +131,6 @@ class HcUserAdmin(UserAdmin):
|
||||
|
||||
self.message_user(request, "%d email(s) sent" % qs.count())
|
||||
|
||||
def send_nag(self, request, qs):
|
||||
for user in qs:
|
||||
user.profile.send_report(nag=True)
|
||||
|
||||
self.message_user(request, "%d email(s) sent" % qs.count())
|
||||
|
||||
|
||||
admin.site.unregister(User)
|
||||
admin.site.register(User, HcUserAdmin)
|
||||
|
||||
|
||||
@admin.register(Credential)
|
||||
class CredentialAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "created", "email", "name")
|
||||
search_fields = ["id", "code", "name", "user__email"]
|
||||
list_filter = ["created"]
|
||||
|
||||
def email(self, obj):
|
||||
return obj.user.email
|
||||
|
@ -1,21 +1,19 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
from hc.accounts.models import Profile
|
||||
from hc.accounts.views import _make_user
|
||||
|
||||
|
||||
class BasicBackend(object):
|
||||
|
||||
def get_user(self, user_id):
|
||||
try:
|
||||
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=None, username=None, token=None):
|
||||
try:
|
||||
profiles = Profile.objects.select_related("user")
|
||||
@ -30,6 +28,7 @@ class ProfileBackend(BasicBackend):
|
||||
|
||||
|
||||
class EmailBackend(BasicBackend):
|
||||
|
||||
def authenticate(self, request=None, username=None, password=None):
|
||||
try:
|
||||
user = User.objects.get(email=username)
|
||||
@ -38,31 +37,3 @@ class EmailBackend(BasicBackend):
|
||||
|
||||
if user.check_password(password):
|
||||
return user
|
||||
|
||||
|
||||
class CustomHeaderBackend(BasicBackend):
|
||||
"""
|
||||
This backend works in conjunction with the ``CustomHeaderMiddleware``,
|
||||
and is used when the server is handling authentication outside of Django.
|
||||
|
||||
"""
|
||||
|
||||
def authenticate(self, request, remote_user_email):
|
||||
"""
|
||||
The email address passed as remote_user_email is considered trusted.
|
||||
Return the User object with the given email address. Create a new User
|
||||
if it does not exist.
|
||||
|
||||
"""
|
||||
|
||||
# This backend should only be used when header-based authentication is enabled
|
||||
assert settings.REMOTE_USER_HEADER
|
||||
# remote_user_email should have a value
|
||||
assert remote_user_email
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=remote_user_email)
|
||||
except User.DoesNotExist:
|
||||
user = _make_user(remote_user_email)
|
||||
|
||||
return user
|
||||
|
@ -1,51 +0,0 @@
|
||||
from functools import wraps
|
||||
import secrets
|
||||
|
||||
from django.core.signing import TimestampSigner, SignatureExpired
|
||||
from django.shortcuts import redirect, render
|
||||
from hc.api.models import TokenBucket
|
||||
from hc.lib import emails
|
||||
|
||||
|
||||
def _session_unsign(request, key, max_age):
|
||||
if key not in request.session:
|
||||
return None
|
||||
|
||||
try:
|
||||
return TimestampSigner().unsign(request.session[key], max_age=max_age)
|
||||
except SignatureExpired:
|
||||
pass
|
||||
|
||||
|
||||
def require_sudo_mode(f):
|
||||
@wraps(f)
|
||||
def wrapper(request, *args, **kwds):
|
||||
assert request.user.is_authenticated
|
||||
|
||||
# is sudo mode active and has not expired yet?
|
||||
if _session_unsign(request, "sudo", 1800) == "active":
|
||||
return f(request, *args, **kwds)
|
||||
|
||||
if not TokenBucket.authorize_sudo_code(request.user):
|
||||
return render(request, "try_later.html")
|
||||
|
||||
# has the user submitted a code to enter sudo mode?
|
||||
if "sudo_code" in request.POST:
|
||||
ours = _session_unsign(request, "sudo_code", 900)
|
||||
if ours and ours == request.POST["sudo_code"]:
|
||||
request.session.pop("sudo_code")
|
||||
request.session["sudo"] = TimestampSigner().sign("active")
|
||||
return redirect(request.path)
|
||||
|
||||
if not _session_unsign(request, "sudo_code", 900):
|
||||
code = "%06d" % secrets.randbelow(1000000)
|
||||
request.session["sudo_code"] = TimestampSigner().sign(code)
|
||||
emails.sudo_code(request.user.email, {"sudo_code": code})
|
||||
|
||||
ctx = {}
|
||||
if "sudo_code" in request.POST:
|
||||
ctx["wrong_code"] = True
|
||||
|
||||
return render(request, "accounts/sudo.html", ctx)
|
||||
|
||||
return wrapper
|
@ -1,104 +1,23 @@
|
||||
import base64
|
||||
import binascii
|
||||
from datetime import timedelta as td
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.models import User
|
||||
from hc.accounts.models import REPORT_CHOICES, Member
|
||||
from hc.api.models import TokenBucket
|
||||
import pytz
|
||||
|
||||
|
||||
class LowercaseEmailField(forms.EmailField):
|
||||
|
||||
def clean(self, value):
|
||||
value = super(LowercaseEmailField, self).clean(value)
|
||||
return value.lower()
|
||||
|
||||
|
||||
class Base64Field(forms.CharField):
|
||||
def to_python(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return base64.b64decode(value.encode())
|
||||
except binascii.Error:
|
||||
raise ValidationError(message="Cannot decode base64")
|
||||
|
||||
|
||||
class SignupForm(forms.Form):
|
||||
# Call it "identity" instead of "email"
|
||||
# to avoid some of the dumber bots
|
||||
identity = LowercaseEmailField(
|
||||
error_messages={"required": "Please enter your email address."}
|
||||
)
|
||||
tz = forms.CharField(required=False)
|
||||
|
||||
def clean_identity(self):
|
||||
v = self.cleaned_data["identity"]
|
||||
if len(v) > 254:
|
||||
raise forms.ValidationError("Address is too long.")
|
||||
|
||||
if User.objects.filter(email=v).exists():
|
||||
raise forms.ValidationError(
|
||||
"An account with this email address already exists."
|
||||
)
|
||||
|
||||
return v
|
||||
|
||||
def clean_tz(self):
|
||||
# Declare tz as "clean" only if we can find it in pytz.all_timezones
|
||||
if self.cleaned_data["tz"] in pytz.all_timezones:
|
||||
return self.cleaned_data["tz"]
|
||||
|
||||
# Otherwise, return None, and *don't* throw a validation exception:
|
||||
# If user's browser reports a timezone we don't recognize, we
|
||||
# should ignore the timezone but still save the rest of the form.
|
||||
|
||||
|
||||
class EmailLoginForm(forms.Form):
|
||||
# Call it "identity" instead of "email"
|
||||
# to avoid some of the dumber bots
|
||||
identity = LowercaseEmailField()
|
||||
|
||||
def clean_identity(self):
|
||||
v = self.cleaned_data["identity"]
|
||||
if not TokenBucket.authorize_login_email(v):
|
||||
raise forms.ValidationError("Too many attempts, please try later.")
|
||||
|
||||
try:
|
||||
self.user = User.objects.get(email=v)
|
||||
except User.DoesNotExist:
|
||||
raise forms.ValidationError("Unknown email address.")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class PasswordLoginForm(forms.Form):
|
||||
class EmailPasswordForm(forms.Form):
|
||||
email = LowercaseEmailField()
|
||||
password = forms.CharField()
|
||||
|
||||
def clean(self):
|
||||
username = self.cleaned_data.get("email")
|
||||
password = self.cleaned_data.get("password")
|
||||
|
||||
if username and password:
|
||||
if not TokenBucket.authorize_login_password(username):
|
||||
raise forms.ValidationError("Too many attempts, please try later.")
|
||||
|
||||
self.user = authenticate(username=username, password=password)
|
||||
if self.user is None or not self.user.is_active:
|
||||
raise forms.ValidationError("Incorrect email or password.")
|
||||
|
||||
return self.cleaned_data
|
||||
password = forms.CharField(required=False)
|
||||
|
||||
|
||||
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):
|
||||
seconds = self.cleaned_data["nag_period"]
|
||||
@ -108,18 +27,9 @@ class ReportSettingsForm(forms.Form):
|
||||
|
||||
return td(seconds=seconds)
|
||||
|
||||
def clean_tz(self):
|
||||
# Declare tz as "clean" only if we can find it in pytz.all_timezones
|
||||
if self.cleaned_data["tz"] in pytz.all_timezones:
|
||||
return self.cleaned_data["tz"]
|
||||
|
||||
# Otherwise, return None, and *don't* throw a validation exception:
|
||||
# If user's browser reports a timezone we don't recognize, we
|
||||
# should ignore the timezone but still save the rest of the form.
|
||||
|
||||
|
||||
class SetPasswordForm(forms.Form):
|
||||
password = forms.CharField(min_length=8)
|
||||
password = forms.CharField()
|
||||
|
||||
|
||||
class ChangeEmailForm(forms.Form):
|
||||
@ -129,51 +39,18 @@ class ChangeEmailForm(forms.Form):
|
||||
def clean_email(self):
|
||||
v = self.cleaned_data["email"]
|
||||
if User.objects.filter(email=v).exists():
|
||||
raise forms.ValidationError("%s is already registered" % v)
|
||||
raise forms.ValidationError("%s is not available" % v)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
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)
|
||||
client_data_json = Base64Field()
|
||||
attestation_object = Base64Field()
|
||||
|
||||
|
||||
class WebAuthnForm(forms.Form):
|
||||
credential_id = Base64Field()
|
||||
client_data_json = Base64Field()
|
||||
authenticator_data = Base64Field()
|
||||
signature = Base64Field()
|
||||
|
||||
|
||||
class TotpForm(forms.Form):
|
||||
error_css_class = "has-error"
|
||||
code = forms.RegexField(regex=r"^\d{6}$")
|
||||
|
||||
def __init__(self, totp, post=None, files=None):
|
||||
self.totp = totp
|
||||
super(TotpForm, self).__init__(post, files)
|
||||
|
||||
def clean_code(self):
|
||||
if not self.totp.verify(self.cleaned_data["code"], valid_window=1):
|
||||
raise forms.ValidationError("The code you entered was incorrect.")
|
||||
|
||||
return self.cleaned_data["code"]
|
||||
class TeamNameForm(forms.Form):
|
||||
team_name = forms.CharField(max_length=200, required=True)
|
||||
|
@ -1,42 +0,0 @@
|
||||
import getpass
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from hc.accounts.forms import SignupForm
|
||||
from hc.accounts.views import _make_user
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Create a super-user account."""
|
||||
|
||||
def handle(self, *args, **options):
|
||||
email = None
|
||||
password = None
|
||||
|
||||
while not email:
|
||||
raw = input("Email address:")
|
||||
form = SignupForm({"identity": raw})
|
||||
if not form.is_valid():
|
||||
self.stderr.write("Error: " + " ".join(form.errors["identity"]))
|
||||
continue
|
||||
|
||||
email = form.cleaned_data["identity"]
|
||||
|
||||
while not password:
|
||||
p1 = getpass.getpass()
|
||||
p2 = getpass.getpass("Password (again):")
|
||||
if p1.strip() == "":
|
||||
self.stderr.write("Error: Blank passwords aren't allowed.")
|
||||
continue
|
||||
if p1 != p2:
|
||||
self.stderr.write("Error: Your passwords didn't match.")
|
||||
continue
|
||||
|
||||
password = p1
|
||||
|
||||
user = _make_user(email)
|
||||
user.set_password(password)
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
|
||||
return "Superuser created successfully."
|
@ -2,42 +2,40 @@ 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):
|
||||
help = """Prune old, inactive user accounts.
|
||||
|
||||
Conditions for removing an user account:
|
||||
- created 1 month ago and never logged in. Does not belong
|
||||
- created 6 months ago and never logged in. Does not belong
|
||||
to any team.
|
||||
Use case: visitor types in their email at the website but
|
||||
never follows through with login.
|
||||
|
||||
- not logged in for 6 months, and has no checks. Does not
|
||||
belong to any team.
|
||||
Use case: user wants to remove their account. So they
|
||||
remove all checks and leave the account at that.
|
||||
|
||||
"""
|
||||
|
||||
def handle(self, *args, **options):
|
||||
month_ago = now() - timedelta(days=30)
|
||||
cutoff = timezone.now() - timedelta(days=180)
|
||||
|
||||
# Old accounts, never logged in, no team memberships
|
||||
q = User.objects.order_by("id")
|
||||
q = User.objects
|
||||
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)
|
||||
n1, _ = q.delete()
|
||||
|
||||
n, summary = q.delete()
|
||||
count = summary.get("auth.User", 0)
|
||||
self.stdout.write("Pruned %d never-logged-in user accounts." % count)
|
||||
# Not logged in for 1 month, 0 checks, no team memberships
|
||||
q = User.objects
|
||||
q = q.annotate(n_checks=Count("check"))
|
||||
q = q.annotate(n_teams=Count("memberships"))
|
||||
q = q.filter(last_login__lt=cutoff, n_checks=0, n_teams=0)
|
||||
n2, _ = q.delete()
|
||||
|
||||
# Profiles scheduled for deletion
|
||||
q = Profile.objects.order_by("id")
|
||||
q = q.filter(deletion_notice_date__lt=month_ago)
|
||||
# Exclude users who have logged in after receiving deletion notice
|
||||
q = q.exclude(user__last_login__gt=F("deletion_notice_date"))
|
||||
|
||||
for profile in q:
|
||||
self.stdout.write("Deleting inactive %s" % profile.user.email)
|
||||
profile.user.delete()
|
||||
|
||||
return "Done!"
|
||||
return "Done! Pruned %d user accounts." % (n1 + n2)
|
||||
|
@ -1,77 +0,0 @@
|
||||
from datetime import timedelta
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now
|
||||
from hc.accounts.models import Profile, Member
|
||||
from hc.api.models import Ping
|
||||
from hc.lib import emails
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Send deletion notices to inactive user accounts.
|
||||
|
||||
Conditions for sending the notice:
|
||||
- deletion notice has not been sent recently
|
||||
- last login more than a year ago
|
||||
- none of the owned projects has invited team members
|
||||
- none of the owned projects has pings in the last year
|
||||
- is on a free plan
|
||||
|
||||
"""
|
||||
|
||||
def pause(self):
|
||||
time.sleep(1)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
year_ago = now() - timedelta(days=365)
|
||||
|
||||
q = Profile.objects.order_by("id")
|
||||
# Exclude accounts with logins in the last year
|
||||
q = q.exclude(user__last_login__gt=year_ago)
|
||||
# Exclude accounts less than a year old
|
||||
q = q.exclude(user__date_joined__gt=year_ago)
|
||||
# Exclude accounts with the deletion notice already sent
|
||||
q = q.exclude(deletion_notice_date__gt=year_ago)
|
||||
# Exclude accounts with activity in the last year
|
||||
q = q.exclude(last_active_date__gt=year_ago)
|
||||
# Exclude paid accounts
|
||||
q = q.exclude(sms_limit__gt=5)
|
||||
|
||||
sent = 0
|
||||
skipped_has_team = 0
|
||||
skipped_has_pings = 0
|
||||
|
||||
for profile in q:
|
||||
members = Member.objects.filter(project__owner_id=profile.user_id)
|
||||
if members.exists():
|
||||
# Don't send deletion notice: this account has team members
|
||||
skipped_has_team += 1
|
||||
continue
|
||||
|
||||
pings = Ping.objects.filter(owner__project__owner_id=profile.user_id)
|
||||
pings = pings.filter(created__gt=year_ago)
|
||||
if pings.exists():
|
||||
# Don't send deletion notice: this account has pings in the last year
|
||||
skipped_has_pings += 1
|
||||
continue
|
||||
|
||||
self.stdout.write("Sending notice to %s" % profile.user.email)
|
||||
|
||||
profile.deletion_notice_date = now()
|
||||
profile.save()
|
||||
|
||||
ctx = {"email": profile.user.email, "support_email": settings.SUPPORT_EMAIL}
|
||||
emails.deletion_notice(profile.user.email, ctx)
|
||||
sent += 1
|
||||
|
||||
# Throttle so we don't send too many emails at once:
|
||||
self.pause()
|
||||
|
||||
return (
|
||||
f"Done!\n"
|
||||
f"* Notices sent: {sent}\n"
|
||||
f"* Skipped (has team members): {skipped_has_team}\n"
|
||||
f"* Skipped (has pings in the last year): {skipped_has_pings}\n"
|
||||
)
|
@ -1,7 +1,3 @@
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||
from django.conf import settings
|
||||
|
||||
from hc.accounts.models import Profile
|
||||
|
||||
|
||||
@ -13,51 +9,11 @@ class TeamAccessMiddleware(object):
|
||||
if not request.user.is_authenticated:
|
||||
return self.get_response(request)
|
||||
|
||||
teams_q = Profile.objects.filter(member__user_id=request.user.id)
|
||||
teams_q = teams_q.select_related("user")
|
||||
request.get_teams = lambda: list(teams_q)
|
||||
|
||||
request.profile = Profile.objects.for_user(request.user)
|
||||
request.team = request.profile.team()
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class CustomHeaderMiddleware(RemoteUserMiddleware):
|
||||
"""
|
||||
Middleware for utilizing Web-server-provided authentication.
|
||||
|
||||
If request.user is not authenticated, then this middleware:
|
||||
- looks for an email address in request.META[settings.REMOTE_USER_HEADER]
|
||||
- looks up and automatically logs in the user with a matching email
|
||||
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
if not settings.REMOTE_USER_HEADER:
|
||||
return
|
||||
|
||||
# Make sure AuthenticationMiddleware is installed
|
||||
assert hasattr(request, "user")
|
||||
|
||||
email = request.META.get(settings.REMOTE_USER_HEADER)
|
||||
if not email:
|
||||
# If specified header doesn't exist or is empty then log out any
|
||||
# authenticated user and return
|
||||
if request.user.is_authenticated:
|
||||
auth.logout(request)
|
||||
return
|
||||
|
||||
# If the user is already authenticated and that user is the user we are
|
||||
# getting passed in the headers, then the correct user is already
|
||||
# persisted in the session and we don't need to continue.
|
||||
if request.user.is_authenticated:
|
||||
if request.user.email == email:
|
||||
return
|
||||
else:
|
||||
# An authenticated user is associated with the request, but
|
||||
# it does not match the authorized user in the header.
|
||||
auth.logout(request)
|
||||
|
||||
# We are seeing this user for the first time in this session, attempt
|
||||
# to authenticate the user.
|
||||
user = auth.authenticate(request, remote_user_email=email)
|
||||
if user:
|
||||
# User is valid. Set request.user and persist user in the session
|
||||
# by logging the user in.
|
||||
request.user = user
|
||||
auth.login(request, user)
|
||||
|
@ -7,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)),
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
|
@ -6,12 +6,14 @@ 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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
@ -7,12 +7,14 @@ 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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
@ -7,12 +7,14 @@ 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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
@ -11,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),
|
||||
),
|
||||
]
|
||||
|
@ -8,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'),
|
||||
),
|
||||
]
|
||||
|
@ -7,12 +7,14 @@ 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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
@ -7,10 +7,14 @@ 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),
|
||||
),
|
||||
]
|
||||
|
@ -7,18 +7,24 @@ 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),
|
||||
),
|
||||
]
|
||||
|
@ -7,12 +7,14 @@ 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),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
@ -7,12 +7,14 @@ 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),
|
||||
),
|
||||
]
|
||||
|
@ -8,24 +8,19 @@ from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0011_profile_sort")]
|
||||
dependencies = [
|
||||
('accounts', '0011_profile_sort'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="nag_period",
|
||||
field=models.DurationField(
|
||||
choices=[
|
||||
(datetime.timedelta(0), "Disabled"),
|
||||
(datetime.timedelta(0, 3600), "Hourly"),
|
||||
(datetime.timedelta(1), "Daily"),
|
||||
],
|
||||
default=datetime.timedelta(0),
|
||||
),
|
||||
model_name='profile',
|
||||
name='nag_period',
|
||||
field=models.DurationField(choices=[(datetime.timedelta(0), 'Disabled'), (datetime.timedelta(0, 3600), 'Hourly'), (datetime.timedelta(1), 'Daily')], default=datetime.timedelta(0)),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="next_nag_date",
|
||||
model_name='profile',
|
||||
name='next_nag_date',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
@ -7,8 +7,13 @@ 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',
|
||||
),
|
||||
]
|
||||
|
@ -9,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),
|
||||
),
|
||||
]
|
||||
|
@ -1,21 +0,0 @@
|
||||
# Generated by Django 2.1.2 on 2018-10-29 18:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0014_auto_20171227_1530")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="api_key_id",
|
||||
field=models.CharField(blank=True, max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="api_key_readonly",
|
||||
field=models.CharField(blank=True, max_length=128),
|
||||
),
|
||||
]
|
@ -1,10 +0,0 @@
|
||||
# Generated by Django 2.1.2 on 2018-11-06 08:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0015_auto_20181029_1858")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="profile", name="bill_to")]
|
@ -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,33 +0,0 @@
|
||||
# Generated by Django 2.1.5 on 2019-01-11 14:49
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_projects(apps, schema_editor):
|
||||
Profile = apps.get_model("accounts", "Profile")
|
||||
Project = apps.get_model("accounts", "Project")
|
||||
Member = apps.get_model("accounts", "Member")
|
||||
for profile in Profile.objects.all():
|
||||
project = Project()
|
||||
project.name = profile.team_name
|
||||
project.owner_id = profile.user_id
|
||||
project.api_key = profile.api_key
|
||||
project.api_key_readonly = profile.api_key_readonly
|
||||
project.save()
|
||||
|
||||
profile.current_project = project
|
||||
profile.save()
|
||||
|
||||
Member.objects.filter(team=profile).update(project=project)
|
||||
|
||||
for profile in Profile.objects.all():
|
||||
if profile.current_team_id:
|
||||
profile.current_project = profile.current_team.current_project
|
||||
profile.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0017_auto_20190112_1426")]
|
||||
|
||||
operations = [migrations.RunPython(create_projects, migrations.RunPython.noop)]
|
@ -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,17 +0,0 @@
|
||||
# Generated by Django 2.1.5 on 2019-01-12 19:50
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_badge_key(apps, schema_editor):
|
||||
Project = apps.get_model("accounts", "Project")
|
||||
for project in Project.objects.select_related("owner").all():
|
||||
project.badge_key = project.owner.username
|
||||
project.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0019_project_badge_key")]
|
||||
|
||||
operations = [migrations.RunPython(set_badge_key, migrations.RunPython.noop)]
|
@ -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,17 +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,28 +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,18 +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,18 +0,0 @@
|
||||
# Generated by Django 3.2.2 on 2021-05-24 07:38
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fill_reports_field(apps, schema_editor):
|
||||
Profile = apps.get_model("accounts", "Profile")
|
||||
Profile.objects.filter(reports_allowed=False).update(reports="off")
|
||||
Profile.objects.filter(reports_allowed=True).update(reports="monthly")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0035_profile_reports"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(fill_reports_field, migrations.RunPython.noop)]
|
@ -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,23 +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,20 +0,0 @@
|
||||
# Generated by Django 3.2.4 on 2021-07-22 13:25
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fill_member_role(apps, schema_editor):
|
||||
Member = apps.get_model("accounts", "Member")
|
||||
Member.objects.filter(rw=False).update(role="r")
|
||||
Member.objects.filter(rw=True).update(role="w")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0040_auto_20210722_1244"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fill_member_role, migrations.RunPython.noop),
|
||||
]
|
@ -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,17 +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,30 +1,21 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
import os
|
||||
from datetime import timedelta
|
||||
import random
|
||||
from secrets import token_urlsafe
|
||||
from urllib.parse import quote, urlencode
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password, make_password
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.signing import TimestampSigner
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from fido2.ctap2 import AttestedCredentialData
|
||||
from hc.lib import emails
|
||||
from hc.lib.date import month_boundaries
|
||||
import pytz
|
||||
|
||||
|
||||
NO_NAG = timedelta()
|
||||
NAG_PERIODS = (
|
||||
(NO_NAG, "Disabled"),
|
||||
(timedelta(hours=1), "Hourly"),
|
||||
(timedelta(days=1), "Daily"),
|
||||
)
|
||||
|
||||
REPORT_CHOICES = (("off", "Off"), ("weekly", "Weekly"), ("monthly", "Monthly"))
|
||||
NAG_PERIODS = ((NO_NAG, "Disabled"),
|
||||
(timedelta(hours=1), "Hourly"),
|
||||
(timedelta(days=1), "Daily"))
|
||||
|
||||
|
||||
def month(dt):
|
||||
@ -42,7 +33,6 @@ class ProfileManager(models.Manager):
|
||||
# If not using payments, set high limits
|
||||
profile.check_limit = 500
|
||||
profile.sms_limit = 500
|
||||
profile.call_limit = 500
|
||||
profile.team_limit = 500
|
||||
|
||||
profile.save()
|
||||
@ -50,37 +40,29 @@ class ProfileManager(models.Manager):
|
||||
|
||||
|
||||
class Profile(models.Model):
|
||||
# Owner:
|
||||
user = models.OneToOneField(User, models.CASCADE, blank=True, null=True)
|
||||
team_name = models.CharField(max_length=200, blank=True)
|
||||
next_report_date = models.DateTimeField(null=True, blank=True)
|
||||
reports = 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 = models.CharField(max_length=128, blank=True)
|
||||
current_team = models.ForeignKey("self", models.SET_NULL, null=True)
|
||||
bill_to = models.TextField(blank=True)
|
||||
last_sms_date = models.DateTimeField(null=True, blank=True)
|
||||
sms_limit = models.IntegerField(default=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")
|
||||
deletion_notice_date = models.DateTimeField(null=True, blank=True)
|
||||
last_active_date = models.DateTimeField(null=True, blank=True)
|
||||
tz = models.CharField(max_length=36, default="UTC")
|
||||
theme = models.CharField(max_length=10, null=True, blank=True)
|
||||
|
||||
totp = models.CharField(max_length=32, null=True, blank=True)
|
||||
totp_created = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
objects = ProfileManager()
|
||||
|
||||
def __str__(self):
|
||||
return "Profile for %s" % self.user.email
|
||||
return self.team_name or self.user.email
|
||||
|
||||
def notifications_url(self):
|
||||
return settings.SITE_ROOT + reverse("hc-notifications")
|
||||
@ -91,8 +73,15 @@ class Profile(models.Model):
|
||||
path = reverse("hc-unsubscribe-reports", args=[signed_username])
|
||||
return settings.SITE_ROOT + path
|
||||
|
||||
def team(self):
|
||||
# compare ids to avoid SQL queries
|
||||
if self.current_team_id and self.current_team_id != self.id:
|
||||
return self.current_team
|
||||
|
||||
return self
|
||||
|
||||
def prepare_token(self, salt):
|
||||
token = token_urlsafe(24)
|
||||
token = urlsafe_b64encode(os.urandom(24)).decode("utf-8")
|
||||
self.token = make_password(token, salt)
|
||||
self.save()
|
||||
return token
|
||||
@ -100,86 +89,52 @@ class Profile(models.Model):
|
||||
def check_token(self, token, salt):
|
||||
return salt in self.token and check_password(token, self.token)
|
||||
|
||||
def send_instant_login_link(self, inviting_project=None, redirect_url=None):
|
||||
def send_instant_login_link(self, inviting_profile=None):
|
||||
token = self.prepare_token("login")
|
||||
path = reverse("hc-check-token", args=[self.user.username, token])
|
||||
if redirect_url:
|
||||
path += "?next=%s" % redirect_url
|
||||
|
||||
ctx = {
|
||||
"button_text": "Sign In",
|
||||
"button_text": "Log In",
|
||||
"button_url": settings.SITE_ROOT + path,
|
||||
"inviting_project": inviting_project,
|
||||
"inviting_profile": inviting_profile
|
||||
}
|
||||
emails.login(self.user.email, ctx)
|
||||
|
||||
def send_transfer_request(self, project):
|
||||
token = self.prepare_token("login")
|
||||
settings_path = reverse("hc-project-settings", args=[project.code])
|
||||
path = reverse("hc-check-token", args=[self.user.username, token])
|
||||
path += "?next=%s" % settings_path
|
||||
|
||||
def send_set_password_link(self):
|
||||
token = self.prepare_token("set-password")
|
||||
path = reverse("hc-set-password", args=[token])
|
||||
ctx = {
|
||||
"button_text": "Project Settings",
|
||||
"button_url": settings.SITE_ROOT + path,
|
||||
"project": project,
|
||||
"button_text": "Set Password",
|
||||
"button_url": settings.SITE_ROOT + path
|
||||
}
|
||||
emails.transfer_request(self.user.email, ctx)
|
||||
emails.set_password(self.user.email, ctx)
|
||||
|
||||
def send_sms_limit_notice(self, transport):
|
||||
ctx = {"transport": transport, "limit": self.sms_limit}
|
||||
if self.sms_limit != 500 and settings.USE_PAYMENTS:
|
||||
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
|
||||
def send_change_email_link(self):
|
||||
token = self.prepare_token("change-email")
|
||||
path = reverse("hc-change-email", args=[token])
|
||||
ctx = {
|
||||
"button_text": "Change Email",
|
||||
"button_url": settings.SITE_ROOT + path
|
||||
}
|
||||
emails.change_email(self.user.email, ctx)
|
||||
|
||||
emails.sms_limit(self.user.email, ctx)
|
||||
def set_api_key(self):
|
||||
self.api_key = urlsafe_b64encode(os.urandom(24))
|
||||
self.save()
|
||||
|
||||
def send_call_limit_notice(self):
|
||||
ctx = {"limit": self.call_limit}
|
||||
if self.call_limit != 500 and settings.USE_PAYMENTS:
|
||||
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
|
||||
def checks_from_all_teams(self):
|
||||
""" Return a queryset of checks from all teams we have access for. """
|
||||
|
||||
emails.call_limit(self.user.email, ctx)
|
||||
|
||||
def projects(self):
|
||||
""" Return a queryset of all projects we have access to. """
|
||||
|
||||
is_owner = Q(owner_id=self.user_id)
|
||||
is_member = Q(member__user_id=self.user_id)
|
||||
q = Project.objects.filter(is_owner | is_member)
|
||||
return q.distinct().order_by("name")
|
||||
|
||||
def annotated_projects(self):
|
||||
""" Return all projects, annotated with 'n_down'. """
|
||||
|
||||
# Subquery for getting project ids
|
||||
project_ids = self.projects().values("id")
|
||||
|
||||
# Main query with the n_down annotation.
|
||||
# Must use the subquery, otherwise ORM gets confused by
|
||||
# joins and group by's
|
||||
q = Project.objects.filter(id__in=project_ids)
|
||||
n_down = Count("check", filter=Q(check__status="down"))
|
||||
q = q.annotate(n_down=n_down)
|
||||
return q.order_by("name")
|
||||
|
||||
def checks_from_all_projects(self):
|
||||
""" Return a queryset of checks from projects we have access to. """
|
||||
|
||||
project_ids = self.projects().values("id")
|
||||
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(project_id__in=project_ids)
|
||||
return Check.objects.filter(user__profile__id__in=team_ids)
|
||||
|
||||
def send_report(self, nag=False):
|
||||
checks = self.checks_from_all_projects()
|
||||
checks = self.checks_from_all_teams()
|
||||
|
||||
# Has there been a ping in last 6 months?
|
||||
result = checks.aggregate(models.Max("last_ping"))
|
||||
last_ping = result["last_ping__max"]
|
||||
|
||||
six_months_ago = timezone.now() - timedelta(days=180)
|
||||
if last_ping is None or last_ping < six_months_ago:
|
||||
# Is there at least one check that has received a ping?
|
||||
if not checks.filter(last_ping__isnull=False).exists():
|
||||
return False
|
||||
|
||||
# Is there at least one check that is down?
|
||||
@ -187,42 +142,42 @@ class Profile(models.Model):
|
||||
if nag and num_down == 0:
|
||||
return False
|
||||
|
||||
# Sort checks by project. Need this because will group by project in
|
||||
# Sort checks by owner. Need this because will group by owner in
|
||||
# template.
|
||||
checks = checks.select_related("project")
|
||||
checks = checks.order_by("project_id")
|
||||
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 = {
|
||||
"List-Unsubscribe": "<%s>" % unsub_url,
|
||||
"X-Bounce-Url": unsub_url,
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
|
||||
boundaries = month_boundaries(months=3)
|
||||
# throw away the current month, keep two previous months
|
||||
boundaries.pop()
|
||||
|
||||
ctx = {
|
||||
"checks": checks,
|
||||
"sort": self.sort,
|
||||
"now": timezone.now(),
|
||||
"unsub_link": unsub_url,
|
||||
"unsub_link": self.reports_unsub_url(),
|
||||
"notifications_url": self.notifications_url(),
|
||||
"nag": nag,
|
||||
"nag_period": self.nag_period.total_seconds(),
|
||||
"num_down": num_down,
|
||||
"month_boundaries": boundaries,
|
||||
"monthly_or_weekly": self.reports,
|
||||
"num_down": num_down
|
||||
}
|
||||
|
||||
emails.report(self.user.email, ctx, headers)
|
||||
emails.report(self.user.email, ctx)
|
||||
return True
|
||||
|
||||
def can_invite(self):
|
||||
return self.member_set.count() < self.team_limit
|
||||
|
||||
def invite(self, user):
|
||||
member = Member(team=self, user=user)
|
||||
member.save()
|
||||
|
||||
# Switch the invited user over to the new team so they
|
||||
# notice the new team on next visit:
|
||||
user.profile.current_team = self
|
||||
user.profile.save()
|
||||
|
||||
user.profile.send_instant_login_link(self)
|
||||
|
||||
def sms_sent_this_month(self):
|
||||
# IF last_sms_date was never set, we have not sent any messages yet.
|
||||
if not self.last_sms_date:
|
||||
@ -246,211 +201,19 @@ class Profile(models.Model):
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def calls_sent_this_month(self):
|
||||
# IF last_call_date was never set, we have not made any phone calls yet.
|
||||
if not self.last_call_date:
|
||||
return 0
|
||||
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(timezone.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):
|
||||
""" If monthly limit not exceeded, increase counter and return True """
|
||||
|
||||
sent_this_month = self.calls_sent_this_month()
|
||||
if sent_this_month >= self.call_limit:
|
||||
return False
|
||||
|
||||
self.calls_sent = sent_this_month + 1
|
||||
self.last_call_date = timezone.now()
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def num_checks_used(self):
|
||||
from hc.api.models import Check
|
||||
|
||||
return Check.objects.filter(project__owner_id=self.user_id).count()
|
||||
|
||||
def num_checks_available(self):
|
||||
return self.check_limit - self.num_checks_used()
|
||||
|
||||
def can_accept(self, project):
|
||||
return project.num_checks() <= self.num_checks_available()
|
||||
|
||||
def update_next_nag_date(self):
|
||||
any_down = self.checks_from_all_projects().filter(status="down").exists()
|
||||
if any_down and self.next_nag_date is None and self.nag_period:
|
||||
self.next_nag_date = timezone.now() + self.nag_period
|
||||
self.save(update_fields=["next_nag_date"])
|
||||
elif not any_down and self.next_nag_date:
|
||||
self.next_nag_date = None
|
||||
self.save(update_fields=["next_nag_date"])
|
||||
|
||||
def choose_next_report_date(self):
|
||||
""" Calculate the target date for the next monthly/weekly report.
|
||||
|
||||
Monthly reports should get sent on 1st of each month, between
|
||||
9AM and 11AM in user's timezone.
|
||||
|
||||
Weekly reports should get sent on Mondays, between
|
||||
9AM and 11AM in user's timezone.
|
||||
|
||||
"""
|
||||
|
||||
if self.reports == "off":
|
||||
return None
|
||||
|
||||
tz = pytz.timezone(self.tz)
|
||||
dt = timezone.now().astimezone(tz)
|
||||
dt = dt.replace(hour=9, minute=0) + timedelta(minutes=random.randrange(0, 120))
|
||||
|
||||
while True:
|
||||
dt += timedelta(days=1)
|
||||
if self.reports == "monthly" and dt.day == 1:
|
||||
return dt
|
||||
elif self.reports == "weekly" and dt.weekday() == 0:
|
||||
return dt
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
code = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
name = models.CharField(max_length=200, blank=True)
|
||||
owner = models.ForeignKey(User, models.CASCADE)
|
||||
api_key = models.CharField(max_length=128, blank=True, db_index=True)
|
||||
api_key_readonly = models.CharField(max_length=128, blank=True, db_index=True)
|
||||
badge_key = models.CharField(max_length=150, unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name or self.owner.email
|
||||
|
||||
@property
|
||||
def owner_profile(self):
|
||||
return Profile.objects.for_user(self.owner)
|
||||
|
||||
def num_checks(self):
|
||||
return self.check_set.count()
|
||||
|
||||
def num_checks_available(self):
|
||||
return self.owner_profile.num_checks_available()
|
||||
|
||||
def set_api_keys(self):
|
||||
self.api_key = token_urlsafe(nbytes=24)
|
||||
self.api_key_readonly = token_urlsafe(nbytes=24)
|
||||
self.save()
|
||||
|
||||
def invite_suggestions(self):
|
||||
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
|
||||
q = q.exclude(memberships__project=self)
|
||||
return q.distinct().order_by("email")
|
||||
|
||||
def can_invite_new_users(self):
|
||||
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
|
||||
used = q.distinct().count()
|
||||
return used < self.owner_profile.team_limit
|
||||
|
||||
def invite(self, user, role):
|
||||
if Member.objects.filter(user=user, project=self).exists():
|
||||
return False
|
||||
|
||||
if self.owner_id == user.id:
|
||||
return False
|
||||
|
||||
Member.objects.create(user=user, project=self, role=role)
|
||||
checks_url = reverse("hc-checks", args=[self.code])
|
||||
user.profile.send_instant_login_link(self, redirect_url=checks_url)
|
||||
return True
|
||||
|
||||
def update_next_nag_dates(self):
|
||||
""" Update next_nag_date on profiles of all members of this project. """
|
||||
|
||||
is_owner = Q(user_id=self.owner_id)
|
||||
is_member = Q(user__memberships__project=self)
|
||||
q = Profile.objects.filter(is_owner | is_member).exclude(nag_period=NO_NAG)
|
||||
|
||||
for profile in q:
|
||||
profile.update_next_nag_date()
|
||||
|
||||
def overall_status(self):
|
||||
if not hasattr(self, "_overall_status"):
|
||||
self._overall_status = "up"
|
||||
for check in self.check_set.all():
|
||||
check_status = check.get_status()
|
||||
if check_status == "grace" and self._overall_status == "up":
|
||||
self._overall_status = "grace"
|
||||
elif check_status == "down":
|
||||
self._overall_status = "down"
|
||||
break
|
||||
|
||||
return self._overall_status
|
||||
|
||||
def get_n_down(self):
|
||||
result = 0
|
||||
for check in self.check_set.all():
|
||||
if check.get_status() == "down":
|
||||
result += 1
|
||||
|
||||
return result
|
||||
|
||||
def have_channel_issues(self):
|
||||
errors = list(self.channel_set.values_list("last_error", flat=True))
|
||||
|
||||
# It's a problem if a project has no integrations at all
|
||||
if len(errors) == 0:
|
||||
return True
|
||||
|
||||
# It's a problem if any integration has a logged error
|
||||
return True if max(errors) else False
|
||||
|
||||
def transfer_request(self):
|
||||
return self.member_set.filter(transfer_request_date__isnull=False).first()
|
||||
|
||||
def dashboard_url(self):
|
||||
if not self.api_key_readonly:
|
||||
return None
|
||||
|
||||
frag = urlencode({self.api_key_readonly: str(self)}, quote_via=quote)
|
||||
return reverse("hc-dashboard") + "#" + frag
|
||||
|
||||
def checks_url(self):
|
||||
return settings.SITE_ROOT + reverse("hc-checks", args=[self.code])
|
||||
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):
|
||||
return self.user.profile.can_accept(self.project)
|
||||
|
||||
@property
|
||||
def is_rw(self):
|
||||
return self.role in (Member.Role.REGULAR, Member.Role.MANAGER)
|
||||
|
||||
|
||||
class Credential(models.Model):
|
||||
code = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
name = models.CharField(max_length=100)
|
||||
user = models.ForeignKey(User, models.CASCADE, related_name="credentials")
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
data = models.BinaryField()
|
||||
|
||||
def unpack(self):
|
||||
unpacked, remaining_data = AttestedCredentialData.unpack_from(self.data)
|
||||
return unpacked
|
||||
|
@ -1,17 +0,0 @@
|
||||
from hc.accounts.models import Project
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class AddProjectTestCase(BaseTestCase):
|
||||
def test_it_works(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post("/projects/add/", {"name": "My Second Project"})
|
||||
|
||||
p = Project.objects.get(owner=self.alice, name="My Second Project")
|
||||
self.assertRedirects(r, "/projects/%s/checks/" % p.code)
|
||||
self.assertEqual(str(p.code), p.badge_key)
|
||||
|
||||
def test_it_rejects_get(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get("/projects/add/")
|
||||
self.assertEqual(r.status_code, 405)
|
@ -1,83 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class AddTotpTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.url = "/accounts/two_factor/totp/"
|
||||
|
||||
def test_it_requires_sudo_mode(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
def test_it_shows_form(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Enter the six-digit code")
|
||||
|
||||
# It should put a "totp_secret" key in the session:
|
||||
self.assertIn("totp_secret", self.client.session)
|
||||
|
||||
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||
def test_it_adds_totp(self, mock_TOTP):
|
||||
mock_TOTP.return_value.verify.return_value = True
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"code": "000000"}
|
||||
r = self.client.post(self.url, payload, follow=True)
|
||||
self.assertRedirects(r, "/accounts/profile/")
|
||||
self.assertContains(r, "Successfully set up the Authenticator app")
|
||||
|
||||
# totp_secret should be gone from the session:
|
||||
self.assertNotIn("totp_secret", self.client.session)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertTrue(self.profile.totp)
|
||||
self.assertTrue(self.profile.totp_created)
|
||||
|
||||
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||
def test_it_handles_wrong_code(self, mock_TOTP):
|
||||
mock_TOTP.return_value.verify.return_value = False
|
||||
mock_TOTP.return_value.provisioning_uri.return_value = "test-uri"
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"code": "000000"}
|
||||
r = self.client.post(self.url, payload, follow=True)
|
||||
self.assertContains(r, "The code you entered was incorrect.")
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIsNone(self.profile.totp)
|
||||
self.assertIsNone(self.profile.totp_created)
|
||||
|
||||
def test_it_checks_if_totp_already_configured(self):
|
||||
self.profile.totp = "0" * 32
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||
def test_it_handles_non_numeric_code(self, mock_TOTP):
|
||||
mock_TOTP.return_value.verify.return_value = False
|
||||
mock_TOTP.return_value.provisioning_uri.return_value = "test-uri"
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"code": "AAAAAA"}
|
||||
r = self.client.post(self.url, payload, follow=True)
|
||||
self.assertContains(r, "Enter a valid value")
|
@ -1,98 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Credential
|
||||
|
||||
|
||||
@override_settings(RP_ID="testserver")
|
||||
class AddWebauthnTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.url = "/accounts/two_factor/webauthn/"
|
||||
|
||||
def test_it_requires_sudo_mode(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
@override_settings(RP_ID=None)
|
||||
def test_it_requires_rp_id(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_it_shows_form(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Add Security Key")
|
||||
|
||||
# It should put a "state" key in the session:
|
||||
self.assertIn("state", self.client.session)
|
||||
|
||||
@patch("hc.accounts.views._get_credential_data")
|
||||
def test_it_adds_credential(self, mock_get_credential_data):
|
||||
mock_get_credential_data.return_value = b"dummy-credential-data"
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {
|
||||
"name": "My New Key",
|
||||
"client_data_json": "e30=",
|
||||
"attestation_object": "e30=",
|
||||
}
|
||||
|
||||
r = self.client.post(self.url, payload, follow=True)
|
||||
self.assertRedirects(r, "/accounts/profile/")
|
||||
self.assertContains(r, "Added security key <strong>My New Key</strong>")
|
||||
|
||||
c = Credential.objects.get()
|
||||
self.assertEqual(c.name, "My New Key")
|
||||
|
||||
def test_it_rejects_bad_base64(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {
|
||||
"name": "My New Key",
|
||||
"client_data_json": "not valid base64",
|
||||
"attestation_object": "not valid base64",
|
||||
}
|
||||
|
||||
r = self.client.post(self.url, payload)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_requires_client_data_json(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {
|
||||
"name": "My New Key",
|
||||
"attestation_object": "e30=",
|
||||
}
|
||||
|
||||
r = self.client.post(self.url, payload)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@patch("hc.accounts.views._get_credential_data")
|
||||
def test_it_handles_authentication_failure(self, mock_get_credential_data):
|
||||
mock_get_credential_data.return_value = None
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {
|
||||
"name": "My New Key",
|
||||
"client_data_json": "e30=",
|
||||
"attestation_object": "e30=",
|
||||
}
|
||||
|
||||
r = self.client.post(self.url, payload, follow=True)
|
||||
self.assertEqual(r.status_code, 400)
|
@ -2,8 +2,9 @@ from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class AccountsAdminTestCase(BaseTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
super(AccountsAdminTestCase, self).setUp()
|
||||
|
||||
self.alice.is_staff = True
|
||||
self.alice.is_superuser = True
|
||||
|
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,43 +1,41 @@
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class ChangeEmailTestCase(BaseTestCase):
|
||||
def test_it_requires_sudo_mode(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/change_email/")
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
def test_it_shows_form(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
self.profile.token = make_password("foo", "change-email")
|
||||
self.profile.save()
|
||||
|
||||
r = self.client.get("/accounts/change_email/")
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/change_email/foo/")
|
||||
self.assertContains(r, "Change Account's Email Address")
|
||||
|
||||
def test_it_updates_email(self):
|
||||
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/done/")
|
||||
self.assertContains(r, "Email Address Updated")
|
||||
self.client.post("/accounts/change_email/foo/", payload)
|
||||
|
||||
self.alice.refresh_from_db()
|
||||
self.assertEqual(self.alice.email, "alice2@example.org")
|
||||
self.assertFalse(self.alice.has_usable_password())
|
||||
|
||||
# The user should have been logged out:
|
||||
self.assertNotIn("_auth_user_id", self.client.session)
|
||||
|
||||
def test_it_requires_unique_email(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": "bob@example.org"}
|
||||
r = self.client.post("/accounts/change_email/", payload)
|
||||
self.assertContains(r, "bob@example.org is already registered")
|
||||
r = self.client.post("/accounts/change_email/foo/", payload)
|
||||
self.assertContains(r, "bob@example.org is not available")
|
||||
|
||||
self.alice.refresh_from_db()
|
||||
self.assertEqual(self.alice.email, "alice@example.org")
|
||||
|
@ -1,24 +1,21 @@
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from hc.accounts.models import Credential
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class CheckTokenTestCase(BaseTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
super(CheckTokenTestCase, self).setUp()
|
||||
self.profile.token = make_password("secret-token", "login")
|
||||
self.profile.save()
|
||||
|
||||
self.checks_url = "/projects/%s/checks/" % self.project.code
|
||||
|
||||
def test_it_shows_form(self):
|
||||
r = self.client.get("/accounts/check_token/alice/secret-token/")
|
||||
self.assertContains(r, "You are about to log in")
|
||||
|
||||
def test_it_redirects(self):
|
||||
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
||||
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
self.assertRedirects(r, "/checks/")
|
||||
|
||||
# After login, token should be blank
|
||||
self.profile.refresh_from_db()
|
||||
@ -30,8 +27,7 @@ class CheckTokenTestCase(BaseTestCase):
|
||||
|
||||
# Login again, when already authenticated
|
||||
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
||||
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
self.assertRedirects(r, "/checks/")
|
||||
|
||||
def test_it_redirects_bad_login(self):
|
||||
# Login with a bad token
|
||||
@ -39,28 +35,3 @@ class CheckTokenTestCase(BaseTestCase):
|
||||
r = self.client.post(url, follow=True)
|
||||
self.assertRedirects(r, "/accounts/login/")
|
||||
self.assertContains(r, "incorrect or expired")
|
||||
|
||||
def test_it_handles_next_parameter(self):
|
||||
url = "/accounts/check_token/alice/secret-token/?next=" + self.channels_url
|
||||
r = self.client.post(url)
|
||||
self.assertRedirects(r, self.channels_url)
|
||||
|
||||
def test_it_ignores_bad_next_parameter(self):
|
||||
url = "/accounts/check_token/alice/secret-token/?next=/evil/"
|
||||
r = self.client.post(url)
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
def test_it_redirects_to_webauthn_form(self):
|
||||
Credential.objects.create(user=self.alice, name="Alices Key")
|
||||
|
||||
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
||||
self.assertRedirects(
|
||||
r, "/accounts/login/two_factor/", fetch_redirect_response=False
|
||||
)
|
||||
|
||||
# It should not log the user in yet
|
||||
self.assertNotIn("_auth_user_id", self.client.session)
|
||||
|
||||
# Instead, it should set 2fa_user_id in the session
|
||||
user_id, email, valid_until = self.client.session["2fa_user"]
|
||||
self.assertEqual(user_id, self.alice.id)
|
||||
|
@ -1,79 +1,57 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from hc.api.models import Check
|
||||
from hc.payments.models import Subscription
|
||||
from hc.test import BaseTestCase
|
||||
from mock import patch
|
||||
|
||||
|
||||
class CloseAccountTestCase(BaseTestCase):
|
||||
def test_it_requires_sudo_mode(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/close/")
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
def test_it_shows_confirmation_form(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get("/accounts/close/")
|
||||
self.assertContains(r, "Close Account?")
|
||||
self.assertContains(r, "1 project")
|
||||
self.assertContains(r, "0 checks")
|
||||
|
||||
@patch("hc.payments.models.braintree")
|
||||
def test_it_works(self, mock_braintree):
|
||||
Check.objects.create(project=self.project, tags="foo a-B_1 baz@")
|
||||
Subscription.objects.create(
|
||||
user=self.alice, subscription_id="123", customer_id="fake-customer-id"
|
||||
)
|
||||
@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)
|
||||
self.assertRedirects(r, "/")
|
||||
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_braintree.Subscription.cancel.called)
|
||||
self.assertTrue(mock_cancel.called)
|
||||
|
||||
# Subscription should be gone
|
||||
self.assertFalse(Subscription.objects.exists())
|
||||
|
||||
def test_it_requires_confirmation(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"confirmation": "incorrect"}
|
||||
r = self.client.post("/accounts/close/", payload)
|
||||
self.assertContains(r, "Close Account?")
|
||||
self.assertContains(r, "has-error")
|
||||
|
||||
# Alice should be still present
|
||||
self.alice.refresh_from_db()
|
||||
self.profile.refresh_from_db()
|
||||
|
||||
def test_partner_removal_works(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"confirmation": "bob@example.org"}
|
||||
r = self.client.post("/accounts/close/", payload)
|
||||
self.assertRedirects(r, "/")
|
||||
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,163 +1,84 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from hc.accounts.models import Credential
|
||||
from hc.api.models import Check, TokenBucket
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Profile
|
||||
from hc.api.models import Check
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class LoginTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.checks_url = f"/projects/{self.project.code}/checks/"
|
||||
|
||||
def test_it_shows_form(self):
|
||||
r = self.client.get("/accounts/login/")
|
||||
self.assertContains(r, "Email Me a Link")
|
||||
|
||||
def test_it_redirects_authenticated_get(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/login/")
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
class LoginTestCase(TestCase):
|
||||
|
||||
def test_it_sends_link(self):
|
||||
form = {"identity": "alice@example.org"}
|
||||
check = Check()
|
||||
check.save()
|
||||
|
||||
session = self.client.session
|
||||
session["welcome_code"] = str(check.code)
|
||||
session.save()
|
||||
|
||||
form = {"email": "alice@example.org"}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(r, "/accounts/login_link_sent/")
|
||||
assert r.status_code == 302
|
||||
|
||||
# And email should have been sent
|
||||
# An user should have been created
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
|
||||
# And email sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
subject = "Log in to %s" % settings.SITE_NAME
|
||||
self.assertEqual(mail.outbox[0].subject, subject)
|
||||
|
||||
def test_it_sends_link_with_next(self):
|
||||
form = {"identity": "alice@example.org"}
|
||||
|
||||
r = self.client.post("/accounts/login/?next=" + self.channels_url, form)
|
||||
self.assertRedirects(r, "/accounts/login_link_sent/")
|
||||
self.assertIn("auto-login", r.cookies)
|
||||
|
||||
# The check_token link should have a ?next= query parameter:
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
body = mail.outbox[0].body
|
||||
self.assertTrue("/?next=" + self.channels_url in body)
|
||||
|
||||
@override_settings(SECRET_KEY="test-secret")
|
||||
def test_it_rate_limits_emails(self):
|
||||
# "d60d..." is sha1("alice@example.orgtest-secret")
|
||||
obj = TokenBucket(value="em-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
|
||||
obj.tokens = 0
|
||||
obj.save()
|
||||
|
||||
form = {"identity": "alice@example.org"}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertContains(r, "Too many attempts")
|
||||
|
||||
# No email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
# And check should be associated with the new user
|
||||
check_again = Check.objects.get(code=check.code)
|
||||
assert check_again.user
|
||||
|
||||
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):
|
||||
form = {"identity": "ALICE@EXAMPLE.ORG"}
|
||||
def test_it_handles_missing_welcome_check(self):
|
||||
|
||||
# This check does not exist in database,
|
||||
# but login should still work.
|
||||
session = self.client.session
|
||||
session["welcome_code"] = "00000000-0000-0000-0000-000000000000"
|
||||
session.save()
|
||||
|
||||
form = {"email": "alice@example.org"}
|
||||
|
||||
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.assertIn("login", self.profile.token)
|
||||
# An user should have been created
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
|
||||
def test_it_handles_password(self):
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
@override_settings(SECRET_KEY="test-secret")
|
||||
def test_it_rate_limits_password_attempts(self):
|
||||
# "d60d..." is sha1("alice@example.orgtest-secret")
|
||||
obj = TokenBucket(value="pw-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
|
||||
obj.tokens = 0
|
||||
obj.save()
|
||||
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertContains(r, "Too many attempts")
|
||||
|
||||
def test_it_handles_password_login_with_redirect(self):
|
||||
check = Check.objects.create(project=self.project)
|
||||
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
|
||||
samples = [self.channels_url, "/checks/%s/details/" % check.code]
|
||||
|
||||
for s in samples:
|
||||
r = self.client.post("/accounts/login/?next=%s" % s, form)
|
||||
self.assertRedirects(r, s)
|
||||
|
||||
def test_it_handles_bad_next_parameter(self):
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
|
||||
samples = [
|
||||
"/evil/",
|
||||
f"https://example.org/projects/{self.project.code}/checks/",
|
||||
]
|
||||
|
||||
for sample in samples:
|
||||
r = self.client.post("/accounts/login/?next=" + sample, form)
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
def test_it_handles_wrong_password(self):
|
||||
form = {
|
||||
"action": "login",
|
||||
"email": "alice@example.org",
|
||||
"password": "wrong password",
|
||||
}
|
||||
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertContains(r, "Incorrect email or password")
|
||||
# And email sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
subject = "Log in to %s" % settings.SITE_NAME
|
||||
self.assertEqual(mail.outbox[0].subject, subject)
|
||||
|
||||
@override_settings(REGISTRATION_OPEN=False)
|
||||
def test_it_obeys_registration_open(self):
|
||||
r = self.client.get("/accounts/login/")
|
||||
self.assertNotContains(r, "Create Your Account")
|
||||
form = {"email": "dan@example.org"}
|
||||
|
||||
def test_it_redirects_to_webauthn_form(self):
|
||||
Credential.objects.create(user=self.alice, name="Alices Key")
|
||||
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(
|
||||
r, "/accounts/login/two_factor/", fetch_redirect_response=False
|
||||
)
|
||||
assert r.status_code == 200
|
||||
self.assertContains(r, "Incorrect email")
|
||||
|
||||
# It should not log the user in yet
|
||||
self.assertNotIn("_auth_user_id", self.client.session)
|
||||
def test_it_ignores_ces(self):
|
||||
alice = User(username="alice", email="alice@example.org")
|
||||
alice.save()
|
||||
|
||||
# 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)
|
||||
form = {"email": "ALICE@EXAMPLE.ORG"}
|
||||
|
||||
def test_it_redirects_to_totp_form(self):
|
||||
self.profile.totp = "0" * 32
|
||||
self.profile.save()
|
||||
|
||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
||||
r = self.client.post("/accounts/login/", form)
|
||||
self.assertRedirects(
|
||||
r, "/accounts/login/two_factor/totp/", fetch_redirect_response=False
|
||||
)
|
||||
assert r.status_code == 302
|
||||
|
||||
# It should not log the user in yet
|
||||
self.assertNotIn("_auth_user_id", self.client.session)
|
||||
# There should be exactly one user:
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
|
||||
# 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)
|
||||
profile = Profile.objects.for_user(alice)
|
||||
self.assertIn("login", profile.token)
|
||||
|
@ -1,97 +0,0 @@
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from hc.api.models import TokenBucket
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class LoginTotpTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# This is the user we're trying to authenticate
|
||||
session = self.client.session
|
||||
session["2fa_user"] = [self.alice.id, self.alice.email, (time.time()) + 300]
|
||||
session.save()
|
||||
|
||||
self.profile.totp = "0" * 32
|
||||
self.profile.save()
|
||||
|
||||
self.url = "/accounts/login/two_factor/totp/"
|
||||
self.checks_url = f"/projects/{self.project.code}/checks/"
|
||||
|
||||
def test_it_shows_form(self):
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Please enter the six-digit code")
|
||||
|
||||
def test_it_requires_unauthenticated_user(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_requires_totp_secret(self):
|
||||
self.profile.totp = None
|
||||
self.profile.save()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_rejects_changed_email(self):
|
||||
session = self.client.session
|
||||
session["2fa_user"] = [self.alice.id, "eve@example.org", int(time.time())]
|
||||
session.save()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_rejects_old_timestamp(self):
|
||||
session = self.client.session
|
||||
session["2fa_user"] = [self.alice.id, self.alice.email, int(time.time()) - 310]
|
||||
session.save()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertRedirects(r, "/accounts/login/")
|
||||
|
||||
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||
def test_it_logs_in(self, mock_TOTP):
|
||||
mock_TOTP.return_value.verify.return_value = True
|
||||
|
||||
r = self.client.post(self.url, {"code": "000000"})
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
self.assertNotIn("2fa_user_id", self.client.session)
|
||||
|
||||
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||
def test_it_redirects_after_login(self, mock_TOTP):
|
||||
mock_TOTP.return_value.verify.return_value = True
|
||||
|
||||
url = self.url + "?next=" + self.channels_url
|
||||
r = self.client.post(url, {"code": "000000"})
|
||||
self.assertRedirects(r, self.channels_url)
|
||||
|
||||
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||
def test_it_handles_authentication_failure(self, mock_TOTP):
|
||||
mock_TOTP.return_value.verify.return_value = False
|
||||
|
||||
r = self.client.post(self.url, {"code": "000000"})
|
||||
self.assertContains(r, "The code you entered was incorrect.")
|
||||
|
||||
def test_it_uses_rate_limiting(self):
|
||||
obj = TokenBucket(value=f"totp-{self.alice.id}")
|
||||
obj.tokens = 0
|
||||
obj.save()
|
||||
|
||||
r = self.client.post(self.url, {"code": "000000"})
|
||||
self.assertContains(r, "Too Many Requests")
|
||||
|
||||
@patch("hc.accounts.views.pyotp.totp.TOTP")
|
||||
def test_it_rejects_used_code(self, mock_TOTP):
|
||||
mock_TOTP.return_value.verify.return_value = True
|
||||
|
||||
obj = TokenBucket(value=f"totpc-{self.alice.id}-000000")
|
||||
obj.tokens = 0
|
||||
obj.save()
|
||||
|
||||
r = self.client.post(self.url, {"code": "000000"})
|
||||
self.assertContains(r, "Too Many Requests")
|
@ -1,149 +0,0 @@
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
@override_settings(RP_ID="testserver")
|
||||
class LoginWebAuthnTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# This is the user we're trying to authenticate
|
||||
session = self.client.session
|
||||
session["2fa_user"] = [self.alice.id, self.alice.email, (time.time()) + 300]
|
||||
session.save()
|
||||
|
||||
self.url = "/accounts/login/two_factor/"
|
||||
self.checks_url = f"/projects/{self.project.code}/checks/"
|
||||
|
||||
def test_it_shows_form(self):
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Waiting for security key")
|
||||
self.assertNotContains(r, "Use authenticator app")
|
||||
|
||||
# It should put a "state" key in the session:
|
||||
self.assertIn("state", self.client.session)
|
||||
|
||||
def test_it_shows_totp_option(self):
|
||||
self.profile.totp = "0" * 32
|
||||
self.profile.save()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Use authenticator app")
|
||||
|
||||
def test_it_preserves_next_parameter_in_totp_url(self):
|
||||
self.profile.totp = "0" * 32
|
||||
self.profile.save()
|
||||
|
||||
url = self.url + "?next=" + self.channels_url
|
||||
r = self.client.get(url)
|
||||
self.assertContains(r, "/login/two_factor/totp/?next=" + self.channels_url)
|
||||
|
||||
def test_it_requires_unauthenticated_user(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_rejects_changed_email(self):
|
||||
session = self.client.session
|
||||
session["2fa_user"] = [self.alice.id, "eve@example.org", int(time.time())]
|
||||
session.save()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_rejects_old_timestamp(self):
|
||||
session = self.client.session
|
||||
session["2fa_user"] = [self.alice.id, self.alice.email, int(time.time()) - 310]
|
||||
session.save()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertRedirects(r, "/accounts/login/")
|
||||
|
||||
@override_settings(RP_ID=None)
|
||||
def test_it_requires_rp_id(self):
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 500)
|
||||
|
||||
@patch("hc.accounts.views._check_credential")
|
||||
def test_it_logs_in(self, mock_check_credential):
|
||||
mock_check_credential.return_value = True
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy-state"
|
||||
session.save()
|
||||
|
||||
payload = {
|
||||
"name": "My New Key",
|
||||
"credential_id": "e30=",
|
||||
"client_data_json": "e30=",
|
||||
"authenticator_data": "e30=",
|
||||
"signature": "e30=",
|
||||
}
|
||||
|
||||
r = self.client.post(self.url, payload)
|
||||
self.assertRedirects(r, self.checks_url)
|
||||
|
||||
self.assertNotIn("state", self.client.session)
|
||||
self.assertNotIn("2fa_user_id", self.client.session)
|
||||
|
||||
@patch("hc.accounts.views._check_credential")
|
||||
def test_it_redirects_after_login(self, mock_check_credential):
|
||||
mock_check_credential.return_value = True
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy-state"
|
||||
session.save()
|
||||
|
||||
payload = {
|
||||
"name": "My New Key",
|
||||
"credential_id": "e30=",
|
||||
"client_data_json": "e30=",
|
||||
"authenticator_data": "e30=",
|
||||
"signature": "e30=",
|
||||
}
|
||||
|
||||
url = self.url + "?next=" + self.channels_url
|
||||
r = self.client.post(url, payload)
|
||||
self.assertRedirects(r, self.channels_url)
|
||||
|
||||
@patch("hc.accounts.views._check_credential")
|
||||
def test_it_handles_bad_base64(self, mock_check_credential):
|
||||
mock_check_credential.return_value = None
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy-state"
|
||||
session.save()
|
||||
|
||||
payload = {
|
||||
"name": "My New Key",
|
||||
"credential_id": "this is not base64 data",
|
||||
"client_data_json": "e30=",
|
||||
"authenticator_data": "e30=",
|
||||
"signature": "e30=",
|
||||
}
|
||||
|
||||
r = self.client.post(self.url, payload)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@patch("hc.accounts.views._check_credential")
|
||||
def test_it_handles_authentication_failure(self, mock_check_credential):
|
||||
mock_check_credential.return_value = None
|
||||
|
||||
session = self.client.session
|
||||
session["state"] = "dummy-state"
|
||||
session.save()
|
||||
|
||||
payload = {
|
||||
"name": "My New Key",
|
||||
"credential_id": "e30=",
|
||||
"client_data_json": "e30=",
|
||||
"authenticator_data": "e30=",
|
||||
"signature": "e30=",
|
||||
}
|
||||
|
||||
r = self.client.post(self.url, payload)
|
||||
self.assertEqual(r.status_code, 400)
|
@ -1,113 +1,60 @@
|
||||
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):
|
||||
result = {"reports": "monthly", "nag_period": "0", "tz": "Europe/Riga"}
|
||||
result.update(kwargs)
|
||||
return result
|
||||
|
||||
def test_it_saves_reports_monthly(self):
|
||||
self.profile.reports = "off"
|
||||
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")
|
||||
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):
|
||||
self.profile.reports = "off"
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.post(self.url, self._payload(reports="weekly"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.reports, "weekly")
|
||||
self.assertEqual(self.profile.next_report_date.weekday(), 0)
|
||||
|
||||
def test_it_saves_reports_off(self):
|
||||
self.profile.reports = "monthly"
|
||||
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):
|
||||
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):
|
||||
self.profile.next_nag_date = now() + td(minutes=30)
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.post(self.url, self._payload(nag_period="3600"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
||||
self.assertIsNone(self.profile.next_nag_date)
|
||||
|
||||
def test_it_does_not_save_nonstandard_nag_period(self):
|
||||
self.profile.nag_period = td(seconds=3600)
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
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):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.post(self.url, self._payload())
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.tz, "Europe/Riga")
|
||||
|
||||
def test_it_ignores_bad_tz(self):
|
||||
self.profile.tz = "Europe/Riga"
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.post(self.url, self._payload(reports="weekly", tz="Foo/Bar"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.reports, "weekly")
|
||||
self.assertEqual(self.profile.tz, "Europe/Riga")
|
||||
|
@ -1,105 +1,174 @@
|
||||
from django.test.utils import override_settings
|
||||
from datetime import timedelta as td
|
||||
from django.core import mail
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import now
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Credential
|
||||
from hc.accounts.models import Member
|
||||
from hc.api.models import Check
|
||||
|
||||
|
||||
class ProfileTestCase(BaseTestCase):
|
||||
def test_it_shows_profile_page(self):
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
self.bobs_profile.refresh_from_db()
|
||||
self.assertFalse(self.bob.memberships.exists())
|
||||
# profile.token should be set now
|
||||
self.profile.refresh_from_db()
|
||||
token = self.profile.token
|
||||
self.assertTrue(len(token) > 10)
|
||||
|
||||
def test_leaving_checks_membership(self):
|
||||
self.client.login(username="charlie@example.org", password="password")
|
||||
# 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)
|
||||
|
||||
form = {"code": str(self.project.code), "leave_project": "1"}
|
||||
def test_it_creates_api_key(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"create_api_key": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_it_shows_project_membership(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Alices Project")
|
||||
self.assertContains(r, "Member")
|
||||
|
||||
def test_it_shows_readonly_project_membership(self):
|
||||
self.bobs_membership.role = "r"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Alices Project")
|
||||
self.assertContains(r, "Read-only")
|
||||
|
||||
def test_it_handles_no_projects(self):
|
||||
self.project.delete()
|
||||
self.profile.refresh_from_db()
|
||||
api_key = self.profile.api_key
|
||||
self.assertTrue(len(api_key) > 10)
|
||||
|
||||
def test_it_revokes_api_key(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "You do not have any projects. Create one!")
|
||||
form = {"revoke_api_key": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
assert r.status_code == 200
|
||||
|
||||
@override_settings(RP_ID=None)
|
||||
def test_it_hides_security_keys_bits_if_rp_id_not_set(self):
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.api_key, "")
|
||||
|
||||
def test_it_sends_report(self):
|
||||
check = Check(name="Test Check", user=self.alice)
|
||||
check.last_ping = now()
|
||||
check.save()
|
||||
|
||||
sent = self.profile.send_report()
|
||||
self.assertTrue(sent)
|
||||
|
||||
# And an email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
message = mail.outbox[0]
|
||||
|
||||
self.assertEqual(message.subject, 'Monthly Report')
|
||||
self.assertIn("Test Check", message.body)
|
||||
|
||||
def test_it_sends_nag(self):
|
||||
check = Check(name="Test Check", user=self.alice)
|
||||
check.status = "down"
|
||||
check.last_ping = now()
|
||||
check.save()
|
||||
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.save()
|
||||
|
||||
sent = self.profile.send_report(nag=True)
|
||||
self.assertTrue(sent)
|
||||
|
||||
# And an email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
message = mail.outbox[0]
|
||||
|
||||
self.assertEqual(message.subject, 'Reminder: 1 check still down')
|
||||
self.assertIn("Test Check", message.body)
|
||||
|
||||
def test_it_skips_nag_if_none_down(self):
|
||||
check = Check(name="Test Check", user=self.alice)
|
||||
check.last_ping = now()
|
||||
check.save()
|
||||
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.save()
|
||||
|
||||
sent = self.profile.send_report(nag=True)
|
||||
self.assertFalse(sent)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_it_adds_team_member(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Two-factor Authentication")
|
||||
self.assertNotContains(r, "Security keys")
|
||||
self.assertNotContains(r, "Add Security Key")
|
||||
form = {"invite_team_member": "1", "email": "frank@example.org"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
@override_settings(RP_ID="testserver")
|
||||
def test_it_handles_no_credentials(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
member_emails = set()
|
||||
for member in self.profile.member_set.all():
|
||||
member_emails.add(member.user.email)
|
||||
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Two-factor Authentication")
|
||||
self.assertContains(r, "Your account does not have any configured two-factor")
|
||||
self.assertEqual(len(member_emails), 2)
|
||||
self.assertTrue("frank@example.org" in member_emails)
|
||||
|
||||
@override_settings(RP_ID="testserver")
|
||||
def test_it_shows_security_key(self):
|
||||
Credential.objects.create(user=self.alice, name="Alices Key")
|
||||
# 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)
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Alices Key")
|
||||
|
||||
def test_it_handles_unusable_password(self):
|
||||
self.alice.set_unusable_password()
|
||||
self.alice.save()
|
||||
|
||||
# Authenticate using the ProfileBackend and a token:
|
||||
token = self.profile.prepare_token("login")
|
||||
self.client.login(username="alice", token=token)
|
||||
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertContains(r, "Set Password")
|
||||
self.assertNotContains(r, "Change Password")
|
||||
|
||||
def test_it_shows_totp(self):
|
||||
self.profile.totp = "0" * 32
|
||||
self.profile.totp_created = "2020-01-01T00:00:00+00:00"
|
||||
def test_it_checks_team_size(self):
|
||||
self.profile.team_limit = 0
|
||||
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")
|
||||
form = {"invite_team_member": "1", "email": "frank@example.org"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_removes_team_member(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"remove_team_member": "1", "email": "bob@example.org"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.assertEqual(Member.objects.count(), 0)
|
||||
|
||||
self.bobs_profile.refresh_from_db()
|
||||
self.assertEqual(self.bobs_profile.current_team, None)
|
||||
|
||||
def test_it_sets_team_name(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"set_team_name": "1", "team_name": "Alpha Team"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.team_name, "Alpha Team")
|
||||
|
||||
def test_it_switches_to_own_team(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
self.client.get("/accounts/profile/")
|
||||
|
||||
# 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")
|
||||
|
||||
form = {"change_email": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
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 = "Change email address on %s" % settings.SITE_NAME
|
||||
self.assertEqual(mail.outbox[0].subject, expected_subject)
|
||||
|
@ -1,114 +0,0 @@
|
||||
from datetime import datetime, timedelta as td
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.core import mail
|
||||
from django.utils.timezone import now, utc
|
||||
from hc.test import BaseTestCase
|
||||
from hc.api.models import Check
|
||||
|
||||
CURRENT_TIME = datetime(2020, 1, 15, tzinfo=utc)
|
||||
MOCK_NOW = Mock(return_value=CURRENT_TIME)
|
||||
|
||||
|
||||
class ProfileModelTestCase(BaseTestCase):
|
||||
@patch("hc.lib.date.timezone.now", MOCK_NOW)
|
||||
def test_it_sends_report(self):
|
||||
check = Check(project=self.project, name="Test Check")
|
||||
check.last_ping = now()
|
||||
check.save()
|
||||
|
||||
sent = self.profile.send_report()
|
||||
self.assertTrue(sent)
|
||||
|
||||
# And an email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
message = mail.outbox[0]
|
||||
|
||||
self.assertEqual(message.subject, "Monthly Report")
|
||||
self.assertIn("Test Check", message.body)
|
||||
|
||||
html, _ = message.alternatives[0]
|
||||
self.assertNotIn("Jan. 2020", html)
|
||||
self.assertIn("Dec. 2019", html)
|
||||
self.assertIn("Nov. 2019", html)
|
||||
self.assertNotIn("Oct. 2019", html)
|
||||
|
||||
def test_it_skips_report_if_no_pings(self):
|
||||
check = Check(project=self.project, name="Test Check")
|
||||
check.save()
|
||||
|
||||
sent = self.profile.send_report()
|
||||
self.assertFalse(sent)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_it_skips_report_if_no_recent_pings(self):
|
||||
check = Check(project=self.project, name="Test Check")
|
||||
check.last_ping = now() - td(days=365)
|
||||
check.save()
|
||||
|
||||
sent = self.profile.send_report()
|
||||
self.assertFalse(sent)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_it_sends_nag(self):
|
||||
check = Check(project=self.project, name="Test Check")
|
||||
check.status = "down"
|
||||
check.last_ping = now()
|
||||
check.save()
|
||||
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.save()
|
||||
|
||||
sent = self.profile.send_report(nag=True)
|
||||
self.assertTrue(sent)
|
||||
|
||||
# And an email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
message = mail.outbox[0]
|
||||
|
||||
self.assertEqual(message.subject, "Reminder: 1 check still down")
|
||||
self.assertIn("Test Check", message.body)
|
||||
|
||||
def test_it_skips_nag_if_none_down(self):
|
||||
check = Check(project=self.project, name="Test Check")
|
||||
check.last_ping = now()
|
||||
check.save()
|
||||
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.save()
|
||||
|
||||
sent = self.profile.send_report(nag=True)
|
||||
self.assertFalse(sent)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_it_sets_next_nag_date(self):
|
||||
Check.objects.create(project=self.project, status="down")
|
||||
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.update_next_nag_date()
|
||||
|
||||
self.assertTrue(self.profile.next_nag_date)
|
||||
|
||||
def test_it_does_not_set_next_nag_date_if_no_nag_period(self):
|
||||
Check.objects.create(project=self.project, status="down")
|
||||
self.profile.update_next_nag_date()
|
||||
self.assertIsNone(self.profile.next_nag_date)
|
||||
|
||||
def test_it_does_not_update_existing_next_nag_date(self):
|
||||
Check.objects.create(project=self.project, status="down")
|
||||
|
||||
original_nag_date = now() - td(minutes=30)
|
||||
|
||||
self.profile.next_nag_date = original_nag_date
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.update_next_nag_date()
|
||||
|
||||
self.assertEqual(self.profile.next_nag_date, original_nag_date)
|
||||
|
||||
def test_it_clears_next_nag_date(self):
|
||||
self.profile.next_nag_date = now()
|
||||
self.profile.update_next_nag_date()
|
||||
self.assertIsNone(self.profile.next_nag_date)
|
@ -1,352 +0,0 @@
|
||||
from django.core import mail
|
||||
|
||||
from django.conf import settings
|
||||
from django.test.utils import override_settings
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Member, Project
|
||||
from hc.api.models import TokenBucket
|
||||
|
||||
|
||||
class ProjectTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.url = "/projects/%s/settings/" % self.project.code
|
||||
|
||||
def test_it_checks_access(self):
|
||||
self.client.login(username="charlie@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_it_allows_team_access(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Change Project Name")
|
||||
|
||||
def test_it_shows_api_keys(self):
|
||||
self.project.api_key_readonly = "R" * 32
|
||||
self.project.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"show_api_keys": "1"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.assertContains(r, "X" * 32)
|
||||
self.assertContains(r, "R" * 32)
|
||||
self.assertContains(r, "Prometheus metrics endpoint")
|
||||
|
||||
def test_it_creates_api_key(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"create_api_keys": "1"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.project.refresh_from_db()
|
||||
api_key = self.project.api_key
|
||||
self.assertTrue(len(api_key) > 10)
|
||||
self.assertFalse("b'" in api_key)
|
||||
|
||||
def test_it_requires_rw_access_to_create_api_key(self):
|
||||
self.bobs_membership.role = "r"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post(self.url, {"create_api_keys": "1"})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_revokes_api_key(self):
|
||||
self.project.api_key_readonly = "R" * 32
|
||||
self.project.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, {"revoke_api_keys": "1"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(self.project.api_key, "")
|
||||
self.assertEqual(self.project.api_key_readonly, "")
|
||||
|
||||
def test_it_requires_rw_access_to_revoke_api_key(self):
|
||||
self.bobs_membership.role = "r"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post(self.url, {"revoke_api_keys": "1"})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_adds_team_member(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "w"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
members = self.project.member_set.all()
|
||||
self.assertEqual(members.count(), 2)
|
||||
|
||||
member = Member.objects.get(
|
||||
project=self.project, user__email="frank@example.org"
|
||||
)
|
||||
|
||||
# The read-write flag should be set
|
||||
self.assertEqual(member.role, member.Role.REGULAR)
|
||||
|
||||
# The new user should not have their own project
|
||||
self.assertFalse(member.user.project_set.exists())
|
||||
|
||||
# And an email should have been sent
|
||||
subj = f"You have been invited to join Alices Project on {settings.SITE_NAME}"
|
||||
self.assertHTMLEqual(mail.outbox[0].subject, subj)
|
||||
|
||||
def test_it_adds_readonly_team_member(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "r"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
member = Member.objects.get(
|
||||
project=self.project, user__email="frank@example.org"
|
||||
)
|
||||
|
||||
self.assertEqual(member.role, member.Role.READONLY)
|
||||
|
||||
def test_it_adds_manager_team_member(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "m"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
member = Member.objects.get(
|
||||
project=self.project, user__email="frank@example.org"
|
||||
)
|
||||
|
||||
# The new user should have role manager
|
||||
self.assertEqual(member.role, member.Role.MANAGER)
|
||||
|
||||
def test_it_adds_member_from_another_team(self):
|
||||
# With team limit at zero, we should not be able to invite any new users
|
||||
self.profile.team_limit = 0
|
||||
self.profile.save()
|
||||
|
||||
# But Charlie will have an existing membership in another Alice's project
|
||||
# so Alice *should* be able to invite Charlie:
|
||||
p2 = Project.objects.create(owner=self.alice)
|
||||
Member.objects.create(user=self.charlie, project=p2)
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
form = {"invite_team_member": "1", "email": "charlie@example.org", "role": "r"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
q = Member.objects.filter(project=self.project, user=self.charlie)
|
||||
self.assertEqual(q.count(), 1)
|
||||
|
||||
# And this should not have affected the rate limit:
|
||||
q = TokenBucket.objects.filter(value="invite-%d" % self.alice.id)
|
||||
self.assertFalse(q.exists())
|
||||
|
||||
def test_it_rejects_duplicate_membership(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"invite_team_member": "1", "email": "bob@example.org", "role": "r"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertContains(r, "bob@example.org is already a member")
|
||||
|
||||
# The number of memberships should have not increased
|
||||
self.assertEqual(self.project.member_set.count(), 1)
|
||||
|
||||
def test_it_rejects_owner_as_a_member(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"invite_team_member": "1", "email": "alice@example.org", "role": "r"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertContains(r, "alice@example.org is already a member")
|
||||
|
||||
# The number of memberships should have not increased
|
||||
self.assertEqual(self.project.member_set.count(), 1)
|
||||
|
||||
def test_it_rejects_too_long_email_addresses(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
aaa = "a" * 300
|
||||
form = {
|
||||
"invite_team_member": "1",
|
||||
"email": f"frank+{aaa}@example.org",
|
||||
"role": "r",
|
||||
}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# No email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
@override_settings(SECRET_KEY="test-secret")
|
||||
def test_it_rate_limits_invites(self):
|
||||
obj = TokenBucket(value="invite-%d" % self.alice.id)
|
||||
obj.tokens = 0
|
||||
obj.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "r"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertContains(r, "Too Many Requests")
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_it_lets_manager_add_team_member(self):
|
||||
# Bob is a manager:
|
||||
self.bobs_membership.role = "m"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "w"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
Member.objects.get(project=self.project, user__email="frank@example.org")
|
||||
|
||||
def test_it_does_not_allow_regular_member_invite_team_members(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "w"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_checks_team_size(self):
|
||||
self.profile.team_limit = 0
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"invite_team_member": "1", "email": "frank@example.org", "role": "r"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_lets_owner_remove_team_member(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"remove_team_member": "1", "email": "bob@example.org"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.assertFalse(Member.objects.exists())
|
||||
|
||||
def test_it_lets_manager_remove_team_member(self):
|
||||
# Bob is a manager:
|
||||
self.bobs_membership.role = "m"
|
||||
self.bobs_membership.save()
|
||||
|
||||
# Bob will try to remove this membership:
|
||||
Member.objects.create(user=self.charlie, project=self.project)
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
form = {"remove_team_member": "1", "email": "charlie@example.org"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
q = Member.objects.filter(user=self.charlie, project=self.project)
|
||||
self.assertFalse(q.exists())
|
||||
|
||||
def test_it_does_not_allow_regular_member_remove_team_member(self):
|
||||
# Bob will try to remove this membership:
|
||||
Member.objects.create(user=self.charlie, project=self.project)
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
form = {"remove_team_member": "1", "email": "charlie@example.org"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_rejects_manager_remove_self(self):
|
||||
self.bobs_membership.role = "m"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
form = {"remove_team_member": "1", "email": "bob@example.org"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
# The number of memberships should have not decreased
|
||||
self.assertEqual(self.project.member_set.count(), 1)
|
||||
|
||||
def test_it_checks_membership_when_removing_team_member(self):
|
||||
self.client.login(username="charlie@example.org", password="password")
|
||||
|
||||
url = "/projects/%s/settings/" % self.charlies_project.code
|
||||
form = {"remove_team_member": "1", "email": "alice@example.org"}
|
||||
r = self.client.post(url, form)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_it_sets_project_name(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"set_project_name": "1", "name": "Alpha Team"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(self.project.name, "Alpha Team")
|
||||
|
||||
def test_it_requires_rw_access_to_set_project_name(self):
|
||||
self.bobs_membership.role = "r"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
form = {"set_project_name": "1", "name": "Alpha Team"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_shows_invite_suggestions(self):
|
||||
p2 = Project.objects.create(owner=self.alice)
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/projects/%s/settings/" % p2.code)
|
||||
self.assertContains(r, "Add Users from Other Teams")
|
||||
self.assertContains(r, "bob@example.org")
|
||||
|
||||
def test_it_requires_rw_access_to_update_project_name(self):
|
||||
self.bobs_membership.role = "r"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
form = {"set_project_name": "1", "name": "Alpha Team"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_hides_actions_for_readonly_users(self):
|
||||
self.bobs_membership.role = "r"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertNotContains(r, "#set-project-name-modal", status_code=200)
|
||||
self.assertNotContains(r, "Show API Keys")
|
||||
|
||||
@override_settings(PROMETHEUS_ENABLED=False)
|
||||
def test_it_hides_prometheus_link_if_prometheus_not_enabled(self):
|
||||
self.project.api_key_readonly = "R" * 32
|
||||
self.project.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, {"show_api_keys": "1"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.assertNotContains(r, "Prometheus metrics endpoint")
|
||||
|
||||
def test_it_requires_rw_access_to_show_api_key(self):
|
||||
self.bobs_membership.role = "r"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post(self.url, {"show_api_keys": "1"})
|
||||
self.assertEqual(r.status_code, 403)
|
@ -1,49 +0,0 @@
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Member, Project
|
||||
from hc.api.models import Check, Channel
|
||||
|
||||
|
||||
class ProjectModelTestCase(BaseTestCase):
|
||||
def test_num_checks_available_handles_multiple_projects(self):
|
||||
# One check in Alice's primary project:
|
||||
Check.objects.create(project=self.project)
|
||||
|
||||
# One check in Alice's secondary project:
|
||||
p2 = Project.objects.create(owner=self.alice)
|
||||
Check.objects.create(project=p2)
|
||||
|
||||
self.assertEqual(self.project.num_checks_available(), 18)
|
||||
|
||||
def test_it_handles_zero_broken_channels(self):
|
||||
Channel.objects.create(kind="webhook", last_error="", project=self.project)
|
||||
|
||||
self.assertFalse(self.project.have_channel_issues())
|
||||
|
||||
def test_it_handles_one_broken_channel(self):
|
||||
Channel.objects.create(kind="webhook", last_error="x", project=self.project)
|
||||
|
||||
self.assertTrue(self.project.have_channel_issues())
|
||||
|
||||
def test_it_handles_no_channels(self):
|
||||
# It's an issue if the project has no channels at all:
|
||||
self.assertTrue(self.project.have_channel_issues())
|
||||
|
||||
def test_it_allows_third_user(self):
|
||||
# Alice is the owner, and Bob is invited -- there is space for the third user:
|
||||
self.assertTrue(self.project.can_invite_new_users())
|
||||
|
||||
def test_it_allows_same_user_in_multiple_projects(self):
|
||||
p2 = Project.objects.create(owner=self.alice)
|
||||
Member.objects.create(user=self.bob, project=p2)
|
||||
|
||||
# Bob's membership in two projects counts as one seat,
|
||||
# one seat should be still free:
|
||||
self.assertTrue(self.project.can_invite_new_users())
|
||||
|
||||
def test_it_checks_team_limit(self):
|
||||
p2 = Project.objects.create(owner=self.alice)
|
||||
Member.objects.create(user=self.charlie, project=p2)
|
||||
|
||||
# Alice and Bob are in one project, Charlie is in another,
|
||||
# so no seats left:
|
||||
self.assertFalse(self.project.can_invite_new_users())
|
@ -1,10 +1,8 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from hc.accounts.management.commands.pruneusers import Command
|
||||
from hc.accounts.models import Project
|
||||
from hc.api.models import Check
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
@ -17,20 +15,26 @@ class PruneUsersTestCase(BaseTestCase):
|
||||
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_removes_old_users_with_zero_checks(self):
|
||||
self.charlie.date_joined = self.year_ago
|
||||
self.charlie.last_login = self.year_ago
|
||||
self.charlie.save()
|
||||
|
||||
Command().handle()
|
||||
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
|
||||
|
||||
def test_it_leaves_team_members_alone(self):
|
||||
self.bob.date_joined = self.year_ago
|
||||
self.bob.last_login = self.year_ago
|
||||
self.bob.save()
|
||||
|
||||
Command(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,56 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.utils import override_settings
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
@override_settings(
|
||||
REMOTE_USER_HEADER="AUTH_USER",
|
||||
AUTHENTICATION_BACKENDS=("hc.accounts.backends.CustomHeaderBackend",),
|
||||
)
|
||||
class RemoteUserHeaderTestCase(BaseTestCase):
|
||||
@override_settings(REMOTE_USER_HEADER=None)
|
||||
def test_it_does_nothing_when_not_configured(self):
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||
|
||||
def test_it_logs_user_in(self):
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||
self.assertContains(r, "alice@example.org")
|
||||
|
||||
def test_it_does_nothing_when_header_not_set(self):
|
||||
r = self.client.get("/accounts/profile/")
|
||||
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||
|
||||
def test_it_does_nothing_when_header_is_empty_string(self):
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="")
|
||||
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||
|
||||
def test_it_creates_user(self):
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="dave@example.org")
|
||||
self.assertContains(r, "dave@example.org")
|
||||
|
||||
q = User.objects.filter(email="dave@example.org")
|
||||
self.assertTrue(q.exists())
|
||||
|
||||
def test_it_logs_out_another_user_when_header_is_empty_string(self):
|
||||
self.client.login(remote_user_email="bob@example.org")
|
||||
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="")
|
||||
self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/")
|
||||
|
||||
def test_it_logs_out_another_user(self):
|
||||
self.client.login(remote_user_email="bob@example.org")
|
||||
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||
self.assertContains(r, "alice@example.org")
|
||||
|
||||
def test_it_handles_already_logged_in_user(self):
|
||||
self.client.login(remote_user_email="alice@example.org")
|
||||
|
||||
with patch("hc.accounts.middleware.auth") as mock_auth:
|
||||
r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org")
|
||||
|
||||
self.assertFalse(mock_auth.authenticate.called)
|
||||
self.assertContains(r, "alice@example.org")
|
@ -1,63 +0,0 @@
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Credential
|
||||
|
||||
|
||||
@override_settings(RP_ID="testserver")
|
||||
class RemoveCredentialTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.c = Credential.objects.create(user=self.alice, name="Alices Key")
|
||||
self.url = f"/accounts/two_factor/{self.c.code}/remove/"
|
||||
|
||||
def test_it_requires_sudo_mode(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
@override_settings(RP_ID=None)
|
||||
def test_it_requires_rp_id(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_it_shows_form(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Remove Security Key")
|
||||
self.assertContains(r, "Alices Key")
|
||||
self.assertContains(r, "two-factor authentication will no longer be active")
|
||||
|
||||
def test_it_skips_warning_when_other_2fa_methods_exist(self):
|
||||
self.profile.totp = "0" * 32
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertNotContains(r, "two-factor authentication will no longer be active")
|
||||
|
||||
def test_it_removes_credential(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.post(self.url, {"remove_credential": ""}, follow=True)
|
||||
self.assertRedirects(r, "/accounts/profile/")
|
||||
self.assertContains(r, "Removed security key <strong>Alices Key</strong>")
|
||||
|
||||
self.assertFalse(self.alice.credentials.exists())
|
||||
|
||||
def test_it_checks_owner(self):
|
||||
self.client.login(username="charlie@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.post(self.url, {"remove_credential": ""})
|
||||
self.assertEqual(r.status_code, 400)
|
@ -1,32 +0,0 @@
|
||||
from hc.api.models import Check
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class RemoveProjectTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.url = "/projects/%s/remove/" % self.project.code
|
||||
|
||||
def test_it_works(self):
|
||||
Check.objects.create(project=self.project, tags="foo a-B_1 baz@")
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url)
|
||||
self.assertRedirects(r, "/")
|
||||
|
||||
# Alice should not own any projects
|
||||
self.assertFalse(self.alice.project_set.exists())
|
||||
|
||||
# Check should be gone
|
||||
self.assertFalse(Check.objects.exists())
|
||||
|
||||
def test_it_rejects_get(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 405)
|
||||
|
||||
def test_it_checks_access(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post(self.url)
|
||||
self.assertEqual(r.status_code, 404)
|
@ -1,46 +0,0 @@
|
||||
from hc.accounts.models import Credential
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class RemoveCredentialTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.profile.totp = "0" * 32
|
||||
self.profile.save()
|
||||
|
||||
self.url = "/accounts/two_factor/totp/remove/"
|
||||
|
||||
def test_it_requires_sudo_mode(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
def test_it_shows_form(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Disable Authenticator App")
|
||||
self.assertContains(r, "two-factor authentication will no longer be active")
|
||||
|
||||
def test_it_skips_warning_when_other_2fa_methods_exist(self):
|
||||
self.c = Credential.objects.create(user=self.alice, name="Alices Key")
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertNotContains(r, "two-factor authentication will no longer be active")
|
||||
|
||||
def test_it_removes_totp(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.post(self.url, {"disable_totp": "1"}, follow=True)
|
||||
self.assertRedirects(r, "/accounts/profile/")
|
||||
self.assertContains(r, "Disabled the authenticator app.")
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIsNone(self.profile.totp)
|
||||
self.assertIsNone(self.profile.totp_created)
|
@ -1,112 +0,0 @@
|
||||
from datetime import timedelta as td
|
||||
import re
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.core import mail
|
||||
from django.utils.timezone import now
|
||||
from hc.accounts.management.commands.senddeletionnotices import Command
|
||||
from hc.accounts.models import Member
|
||||
from hc.api.models import Check, Ping
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
def counts(result):
|
||||
""" Extract integer values from command's return value. """
|
||||
return [int(s) for s in re.findall(r"\d+", result)]
|
||||
|
||||
|
||||
class SendDeletionNoticesTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Make alice eligible for notice -- signed up more than 1 year ago
|
||||
self.alice.date_joined = now() - td(days=500)
|
||||
self.alice.save()
|
||||
|
||||
self.profile.sms_limit = 5
|
||||
self.profile.save()
|
||||
|
||||
# remove members from alice's project
|
||||
self.project.member_set.all().delete()
|
||||
|
||||
def test_it_sends_notice(self):
|
||||
cmd = Command(stdout=Mock())
|
||||
cmd.pause = Mock() # don't pause for 1s
|
||||
|
||||
result = cmd.handle()
|
||||
self.assertEqual(counts(result), [1, 0, 0])
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertTrue(self.profile.deletion_notice_date)
|
||||
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.subject, "Inactive Account Notification")
|
||||
|
||||
def test_it_checks_last_login(self):
|
||||
# alice has logged in recently:
|
||||
self.alice.last_login = now() - td(days=15)
|
||||
self.alice.save()
|
||||
|
||||
result = Command(stdout=Mock()).handle()
|
||||
self.assertEqual(counts(result), [0, 0, 0])
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIsNone(self.profile.deletion_notice_date)
|
||||
|
||||
def test_it_checks_date_joined(self):
|
||||
# alice signed up recently:
|
||||
self.alice.date_joined = now() - td(days=15)
|
||||
self.alice.save()
|
||||
|
||||
result = Command(stdout=Mock()).handle()
|
||||
self.assertEqual(counts(result), [0, 0, 0])
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIsNone(self.profile.deletion_notice_date)
|
||||
|
||||
def test_it_checks_deletion_notice_date(self):
|
||||
# alice has already received a deletion notice
|
||||
self.profile.deletion_notice_date = now() - td(days=15)
|
||||
self.profile.save()
|
||||
|
||||
result = Command(stdout=Mock()).handle()
|
||||
self.assertEqual(counts(result), [0, 0, 0])
|
||||
|
||||
def test_it_checks_sms_limit(self):
|
||||
# alice has a paid account
|
||||
self.profile.sms_limit = 50
|
||||
self.profile.save()
|
||||
|
||||
result = Command(stdout=Mock()).handle()
|
||||
self.assertEqual(counts(result), [0, 0, 0])
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIsNone(self.profile.deletion_notice_date)
|
||||
|
||||
def test_it_checks_team_members(self):
|
||||
# bob has access to alice's project
|
||||
Member.objects.create(user=self.bob, project=self.project)
|
||||
|
||||
result = Command(stdout=Mock()).handle()
|
||||
self.assertEqual(counts(result), [0, 1, 0])
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIsNone(self.profile.deletion_notice_date)
|
||||
|
||||
def test_it_checks_recent_pings(self):
|
||||
check = Check.objects.create(project=self.project)
|
||||
Ping.objects.create(owner=check)
|
||||
|
||||
result = Command(stdout=Mock()).handle()
|
||||
self.assertEqual(counts(result), [0, 0, 1])
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIsNone(self.profile.deletion_notice_date)
|
||||
|
||||
def test_it_checks_last_active_date(self):
|
||||
# alice has been browsing the site recently
|
||||
self.profile.last_active_date = now() - td(days=15)
|
||||
self.profile.save()
|
||||
|
||||
result = Command(stdout=Mock()).handle()
|
||||
self.assertEqual(counts(result), [0, 0, 0])
|
@ -1,40 +0,0 @@
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class SetPasswordTestCase(BaseTestCase):
|
||||
def test_it_requires_sudo_mode(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get("/accounts/set_password/")
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
def test_it_shows_form(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
r = self.client.get("/accounts/set_password/")
|
||||
self.assertContains(r, "Please pick a password")
|
||||
|
||||
def test_it_sets_password(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"password": "correct horse battery staple"}
|
||||
r = self.client.post("/accounts/set_password/", payload)
|
||||
self.assertRedirects(r, "/accounts/profile/")
|
||||
|
||||
old_password = self.alice.password
|
||||
self.alice.refresh_from_db()
|
||||
self.assertNotEqual(self.alice.password, old_password)
|
||||
|
||||
def test_post_checks_length(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
self.set_sudo_flag()
|
||||
|
||||
payload = {"password": "abc"}
|
||||
r = self.client.post("/accounts/set_password/", payload)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
old_password = self.alice.password
|
||||
self.alice.refresh_from_db()
|
||||
self.assertEqual(self.alice.password, old_password)
|
@ -1,104 +0,0 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from hc.accounts.models import Profile, Project
|
||||
from hc.api.models import Channel, Check
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SignupTestCase(TestCase):
|
||||
@override_settings(USE_PAYMENTS=False)
|
||||
def test_it_works(self):
|
||||
form = {"identity": "alice@example.org", "tz": "Europe/Riga"}
|
||||
|
||||
r = self.client.post("/accounts/signup/", form)
|
||||
self.assertContains(r, "Account created")
|
||||
self.assertIn("auto-login", r.cookies)
|
||||
|
||||
# An user should have been created
|
||||
user = User.objects.get()
|
||||
|
||||
# A profile should have been created
|
||||
profile = Profile.objects.get()
|
||||
self.assertEqual(profile.check_limit, 500)
|
||||
self.assertEqual(profile.sms_limit, 500)
|
||||
self.assertEqual(profile.call_limit, 500)
|
||||
self.assertEqual(profile.tz, "Europe/Riga")
|
||||
|
||||
# And email sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
subject = "Log in to %s" % settings.SITE_NAME
|
||||
self.assertEqual(mail.outbox[0].subject, subject)
|
||||
|
||||
# A project should have been created
|
||||
project = Project.objects.get()
|
||||
self.assertEqual(project.owner, user)
|
||||
self.assertEqual(project.badge_key, user.username)
|
||||
|
||||
# And check should be associated with the new user
|
||||
check = Check.objects.get()
|
||||
self.assertEqual(check.name, "My First Check")
|
||||
self.assertEqual(check.project, project)
|
||||
|
||||
# A channel should have been created
|
||||
channel = Channel.objects.get()
|
||||
self.assertEqual(channel.project, project)
|
||||
|
||||
@override_settings(USE_PAYMENTS=True)
|
||||
def test_it_sets_limits(self):
|
||||
form = {"identity": "alice@example.org", "tz": ""}
|
||||
|
||||
self.client.post("/accounts/signup/", form)
|
||||
|
||||
profile = Profile.objects.get()
|
||||
self.assertEqual(profile.check_limit, 20)
|
||||
self.assertEqual(profile.sms_limit, 5)
|
||||
self.assertEqual(profile.call_limit, 0)
|
||||
|
||||
@override_settings(REGISTRATION_OPEN=False)
|
||||
def test_it_obeys_registration_open(self):
|
||||
form = {"identity": "dan@example.org", "tz": ""}
|
||||
|
||||
r = self.client.post("/accounts/signup/", form)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_ignores_case(self):
|
||||
form = {"identity": "ALICE@EXAMPLE.ORG", "tz": ""}
|
||||
self.client.post("/accounts/signup/", form)
|
||||
|
||||
# There should be exactly one user:
|
||||
q = User.objects.filter(email="alice@example.org")
|
||||
self.assertTrue(q.exists)
|
||||
|
||||
def test_it_checks_for_existing_users(self):
|
||||
alice = User(username="alice", email="alice@example.org")
|
||||
alice.save()
|
||||
|
||||
form = {"identity": "alice@example.org", "tz": ""}
|
||||
r = self.client.post("/accounts/signup/", form)
|
||||
self.assertContains(r, "already exists")
|
||||
|
||||
def test_it_checks_syntax(self):
|
||||
form = {"identity": "alice at example org", "tz": ""}
|
||||
r = self.client.post("/accounts/signup/", form)
|
||||
self.assertContains(r, "Enter a valid email address")
|
||||
|
||||
def test_it_checks_length(self):
|
||||
aaa = "a" * 300
|
||||
form = {"identity": f"alice+{aaa}@example.org", "tz": ""}
|
||||
r = self.client.post("/accounts/signup/", form)
|
||||
self.assertContains(r, "Address is too long.")
|
||||
|
||||
self.assertFalse(User.objects.exists())
|
||||
|
||||
@override_settings(USE_PAYMENTS=False)
|
||||
def test_it_ignores_bad_tz(self):
|
||||
form = {"identity": "alice@example.org", "tz": "Foo/Bar"}
|
||||
|
||||
r = self.client.post("/accounts/signup/", form)
|
||||
self.assertContains(r, "Account created")
|
||||
self.assertIn("auto-login", r.cookies)
|
||||
|
||||
profile = Profile.objects.get()
|
||||
self.assertEqual(profile.tz, "UTC")
|
@ -1,73 +0,0 @@
|
||||
from django.core import mail
|
||||
from django.core.signing import TimestampSigner
|
||||
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Credential
|
||||
from hc.api.models import TokenBucket
|
||||
|
||||
|
||||
class SudoModeTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.c = Credential.objects.create(user=self.alice, name="Alices Key")
|
||||
self.url = f"/accounts/set_password/"
|
||||
|
||||
def test_it_sends_code(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "We have sent a confirmation code")
|
||||
|
||||
# A code should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to[0], "alice@example.org")
|
||||
self.assertIn("Confirmation code", email.subject)
|
||||
|
||||
def test_it_accepts_code(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
session = self.client.session
|
||||
session["sudo_code"] = TimestampSigner().sign("123456")
|
||||
session.save()
|
||||
|
||||
r = self.client.post(self.url, {"sudo_code": "123456"})
|
||||
self.assertRedirects(r, self.url)
|
||||
|
||||
# sudo mode should now be active
|
||||
self.assertIn("sudo", self.client.session)
|
||||
|
||||
def test_it_rejects_incorrect_code(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
session = self.client.session
|
||||
session["sudo_code"] = TimestampSigner().sign("123456")
|
||||
session.save()
|
||||
|
||||
r = self.client.post(self.url, {"sudo_code": "000000"})
|
||||
self.assertContains(r, "Not a valid code.")
|
||||
|
||||
# sudo mode should *not* be active
|
||||
self.assertNotIn("sudo", self.client.session)
|
||||
|
||||
def test_it_passes_through_if_sudo_mode_is_active(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
session = self.client.session
|
||||
session["sudo"] = TimestampSigner().sign("active")
|
||||
session.save()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Please pick a password")
|
||||
|
||||
def test_it_uses_rate_limiting(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
obj = TokenBucket(value=f"sudo-{self.alice.id}")
|
||||
obj.tokens = 0
|
||||
obj.save()
|
||||
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Too Many Requests")
|
44
hc/accounts/tests/test_switch_team.py
Normal file
44
hc/accounts/tests/test_switch_team.py
Normal file
@ -0,0 +1,44 @@
|
||||
from hc.test import BaseTestCase
|
||||
from hc.api.models import Check
|
||||
|
||||
|
||||
class SwitchTeamTestCase(BaseTestCase):
|
||||
|
||||
def test_it_switches(self):
|
||||
c = Check(user=self.alice, name="This belongs to Alice")
|
||||
c.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
url = "/accounts/switch_team/%s/" % self.alice.username
|
||||
r = self.client.get(url, follow=True)
|
||||
|
||||
self.assertContains(r, "This belongs to Alice")
|
||||
|
||||
def test_it_checks_team_membership(self):
|
||||
self.client.login(username="charlie@example.org", password="password")
|
||||
|
||||
url = "/accounts/switch_team/%s/" % self.alice.username
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_switches_to_own_team(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
url = "/accounts/switch_team/%s/" % self.alice.username
|
||||
r = self.client.get(url, follow=True)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_it_handles_invalid_username(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
url = "/accounts/switch_team/dave/"
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_requires_login(self):
|
||||
url = "/accounts/switch_team/%s/" % self.alice.username
|
||||
r = self.client.get(url)
|
||||
|
||||
expected_url = "/accounts/login/?next=/accounts/switch_team/alice/"
|
||||
self.assertRedirects(r, expected_url)
|
@ -4,6 +4,7 @@ from hc.accounts.models import Profile
|
||||
|
||||
|
||||
class TeamAccessMiddlewareTestCase(TestCase):
|
||||
|
||||
def test_it_handles_missing_profile(self):
|
||||
user = User(username="ned", email="ned@example.org")
|
||||
user.set_password("password")
|
||||
|
@ -1,169 +0,0 @@
|
||||
from django.core import mail
|
||||
from django.utils.timezone import now
|
||||
from hc.accounts.models import Member
|
||||
from hc.api.models import Check
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class TransferProjectTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
Check.objects.create(project=self.project)
|
||||
|
||||
self.url = "/projects/%s/settings/" % self.project.code
|
||||
|
||||
def test_transfer_project_works(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"transfer_project": "1", "email": "bob@example.org"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertContains(r, "Transfer initiated!")
|
||||
|
||||
self.bobs_membership.refresh_from_db()
|
||||
self.assertIsNotNone(self.bobs_membership.transfer_request_date)
|
||||
|
||||
# Bob should receive an email notification
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
body = mail.outbox[0].body
|
||||
self.assertTrue("/?next=" + self.url in body)
|
||||
|
||||
def test_transfer_project_checks_ownership(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
||||
form = {"transfer_project": "1", "email": "bob@example.org"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_transfer_project_checks_membership(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"transfer_project": "1", "email": "charlie@example.org"}
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_cancel_works(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, {"cancel_transfer": "1"})
|
||||
self.assertContains(r, "Transfer cancelled!")
|
||||
|
||||
self.bobs_membership.refresh_from_db()
|
||||
self.assertIsNone(self.bobs_membership.transfer_request_date)
|
||||
|
||||
def test_cancel_checks_ownership(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post(self.url, {"cancel_transfer": "1"})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
self.bobs_membership.refresh_from_db()
|
||||
self.assertIsNotNone(self.bobs_membership.transfer_request_date)
|
||||
|
||||
def test_it_shows_transfer_request(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "would like to transfer")
|
||||
self.assertNotContains(r, "upgrade your account first")
|
||||
|
||||
def test_it_shows_transfer_request_with_limit_notice(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.bobs_profile.check_limit = 0
|
||||
self.bobs_profile.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "upgrade your account first")
|
||||
|
||||
def test_accept_works(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post(self.url, {"accept_transfer": "1"})
|
||||
self.assertContains(r, "You are now the owner of this project!")
|
||||
|
||||
self.project.refresh_from_db()
|
||||
# Bob should now be the owner
|
||||
self.assertEqual(self.project.owner, self.bob)
|
||||
|
||||
# Alice, the previous owner, should now be a member
|
||||
m = Member.objects.get(project=self.project, user=self.alice)
|
||||
self.assertEqual(m.role, Member.Role.REGULAR)
|
||||
|
||||
def test_accept_requires_a_transfer_request(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post(self.url, {"accept_transfer": "1"})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
self.project.refresh_from_db()
|
||||
# Alice should still be the owner
|
||||
self.assertEqual(self.project.owner, self.alice)
|
||||
|
||||
def test_only_the_proposed_owner_can_accept(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, {"accept_transfer": "1"})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_it_checks_limits(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.bobs_profile.check_limit = 0
|
||||
self.bobs_profile.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post(self.url, {"accept_transfer": "1"})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_reject_works(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.post(self.url, {"reject_transfer": "1"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.project.refresh_from_db()
|
||||
# Alice should still be the owner
|
||||
self.assertEqual(self.project.owner, self.alice)
|
||||
|
||||
# The transfer_request_date should be cleared out
|
||||
self.bobs_membership.refresh_from_db()
|
||||
self.assertIsNone(self.bobs_membership.transfer_request_date)
|
||||
|
||||
def test_only_the_proposed_owner_can_reject(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, {"reject_transfer": "1"})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_readonly_user_can_accept(self):
|
||||
self.bobs_membership.transfer_request_date = now()
|
||||
self.bobs_membership.role = "r"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
self.client.post(self.url, {"accept_transfer": "1"})
|
||||
|
||||
self.project.refresh_from_db()
|
||||
# Bob should now be the owner
|
||||
self.assertEqual(self.project.owner, self.bob)
|
||||
|
||||
# Alice, the previous owner, should now be a *regular* member
|
||||
m = Member.objects.get(user=self.alice, project=self.project)
|
||||
self.assertEqual(m.role, "w")
|
@ -1,6 +1,4 @@
|
||||
from datetime import timedelta as td
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core import signing
|
||||
from django.utils.timezone import now
|
||||
@ -8,55 +6,40 @@ from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class UnsubscribeReportsTestCase(BaseTestCase):
|
||||
def test_it_unsubscribes(self):
|
||||
|
||||
def test_token_works(self):
|
||||
self.profile.next_report_date = now()
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.next_nag_date = now()
|
||||
self.profile.save()
|
||||
|
||||
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
||||
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||
|
||||
r = self.client.post(url)
|
||||
self.assertContains(r, "Unsubscribed")
|
||||
token = signing.Signer().sign("foo")
|
||||
url = "/accounts/unsubscribe_reports/alice/?token=%s" % token
|
||||
r = self.client.get(url)
|
||||
self.assertContains(r, "You have been unsubscribed")
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.reports, "off")
|
||||
self.assertFalse(self.profile.reports_allowed)
|
||||
self.assertIsNone(self.profile.next_report_date)
|
||||
|
||||
self.assertEqual(self.profile.nag_period.total_seconds(), 0)
|
||||
self.assertIsNone(self.profile.next_nag_date)
|
||||
|
||||
def test_bad_token_gets_rejected(self):
|
||||
url = "/accounts/unsubscribe_reports/alice/?token=invalid"
|
||||
r = self.client.get(url)
|
||||
self.assertContains(r, "Incorrect Link")
|
||||
|
||||
def test_signed_username_works(self):
|
||||
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
||||
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||
r = self.client.get(url)
|
||||
self.assertContains(r, "You have been unsubscribed")
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertFalse(self.profile.reports_allowed)
|
||||
|
||||
def test_bad_signature_gets_rejected(self):
|
||||
url = "/accounts/unsubscribe_reports/invalid/"
|
||||
r = self.client.get(url)
|
||||
self.assertContains(r, "Incorrect Link")
|
||||
|
||||
def test_it_serves_confirmation_form(self):
|
||||
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
||||
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertContains(r, "Please press the button below")
|
||||
self.assertNotContains(r, "submit()")
|
||||
|
||||
def test_aged_signature_autosubmits(self):
|
||||
with patch("django.core.signing.time") as mock_time:
|
||||
mock_time.time.return_value = time.time() - 301
|
||||
signer = signing.TimestampSigner(salt="reports")
|
||||
sig = signer.sign("alice")
|
||||
|
||||
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertContains(r, "Please press the button below")
|
||||
self.assertContains(r, "submit()")
|
||||
|
||||
def test_it_handles_missing_user(self):
|
||||
self.alice.delete()
|
||||
|
||||
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
||||
url = "/accounts/unsubscribe_reports/%s/" % sig
|
||||
|
||||
r = self.client.post(url)
|
||||
self.assertContains(r, "Unsubscribed")
|
||||
|
@ -1,36 +1,36 @@
|
||||
from django.urls import path
|
||||
from django.conf.urls import url
|
||||
from hc.accounts import views
|
||||
|
||||
urlpatterns = [
|
||||
path("login/", views.login, name="hc-login"),
|
||||
path("login/two_factor/", views.login_webauthn, name="hc-login-webauthn"),
|
||||
path("login/two_factor/totp/", views.login_totp, name="hc-login-totp"),
|
||||
path("logout/", views.logout, name="hc-logout"),
|
||||
path("signup/", views.signup, name="hc-signup"),
|
||||
path("login_link_sent/", views.login_link_sent, name="hc-login-link-sent"),
|
||||
path(
|
||||
"check_token/<slug:username>/<slug:token>/",
|
||||
views.check_token,
|
||||
name="hc-check-token",
|
||||
),
|
||||
path("profile/", views.profile, name="hc-profile"),
|
||||
path("profile/appearance/", views.appearance, name="hc-appearance"),
|
||||
path("profile/notifications/", views.notifications, name="hc-notifications"),
|
||||
path("close/", views.close, name="hc-close"),
|
||||
path(
|
||||
"unsubscribe_reports/<str:signed_username>/",
|
||||
views.unsubscribe_reports,
|
||||
name="hc-unsubscribe-reports",
|
||||
),
|
||||
path("set_password/", views.set_password, name="hc-set-password"),
|
||||
path("change_email/done/", views.change_email_done, name="hc-change-email-done"),
|
||||
path("change_email/", views.change_email, name="hc-change-email"),
|
||||
path("two_factor/webauthn/", views.add_webauthn, name="hc-add-webauthn"),
|
||||
path("two_factor/totp/", views.add_totp, name="hc-add-totp"),
|
||||
path("two_factor/totp/remove/", views.remove_totp, name="hc-remove-totp"),
|
||||
path(
|
||||
"two_factor/<uuid:code>/remove/",
|
||||
views.remove_credential,
|
||||
name="hc-remove-credential",
|
||||
),
|
||||
url(r'^login/$', views.login, name="hc-login"),
|
||||
url(r'^logout/$', views.logout, name="hc-logout"),
|
||||
url(r'^login_link_sent/$',
|
||||
views.login_link_sent, name="hc-login-link-sent"),
|
||||
|
||||
url(r'^link_sent/$',
|
||||
views.link_sent, name="hc-link-sent"),
|
||||
|
||||
url(r'^check_token/([\w-]+)/([\w-]+)/$',
|
||||
views.check_token, name="hc-check-token"),
|
||||
|
||||
url(r'^profile/$', views.profile, name="hc-profile"),
|
||||
url(r'^profile/notifications/$', views.notifications, name="hc-notifications"),
|
||||
url(r'^profile/badges/$', views.badges, name="hc-badges"),
|
||||
url(r'^close/$', views.close, name="hc-close"),
|
||||
|
||||
url(r'^unsubscribe_reports/([\w\:-]+)/$',
|
||||
views.unsubscribe_reports, name="hc-unsubscribe-reports"),
|
||||
|
||||
url(r'^set_password/([\w-]+)/$',
|
||||
views.set_password, name="hc-set-password"),
|
||||
|
||||
url(r'^change_email/done/$',
|
||||
views.change_email_done, name="hc-change-email-done"),
|
||||
|
||||
url(r'^change_email/([\w-]+)/$',
|
||||
views.change_email, name="hc-change-email"),
|
||||
|
||||
url(r'^switch_team/([\w-]+)/$',
|
||||
views.switch_team, name="hc-switch-team"),
|
||||
|
||||
]
|
||||
|
1000
hc/accounts/views.py
1000
hc/accounts/views.py
File diff suppressed because it is too large
Load Diff
199
hc/api/admin.py
199
hc/api/admin.py
@ -1,61 +1,50 @@
|
||||
from django.contrib import admin
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import connection
|
||||
from django.db.models import F
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from hc.api.models import Channel, Check, Flip, Notification, Ping
|
||||
from hc.api.models import Channel, Check, Notification, Ping
|
||||
from hc.lib.date import format_duration
|
||||
|
||||
|
||||
class OwnershipListFilter(admin.SimpleListFilter):
|
||||
title = "Ownership"
|
||||
parameter_name = 'ownership'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('assigned', "Assigned"),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() == 'assigned':
|
||||
return queryset.filter(user__isnull=False)
|
||||
return queryset
|
||||
|
||||
|
||||
@admin.register(Check)
|
||||
class ChecksAdmin(admin.ModelAdmin):
|
||||
|
||||
class Media:
|
||||
css = {"all": ("css/admin/checks.css",)}
|
||||
|
||||
search_fields = ["name", "code", "project__owner__email"]
|
||||
raw_id_fields = ("project",)
|
||||
list_display = (
|
||||
"id",
|
||||
"name_tags",
|
||||
"project_",
|
||||
"created",
|
||||
"n_pings",
|
||||
"timeout_schedule",
|
||||
"status",
|
||||
"last_start",
|
||||
"last_ping",
|
||||
)
|
||||
list_filter = ("status", "kind", "last_ping", "last_start")
|
||||
css = {
|
||||
'all': ('css/admin/checks.css',)
|
||||
}
|
||||
|
||||
search_fields = ["name", "user__email", "code"]
|
||||
list_display = ("id", "name_tags", "created", "code", "timeout_schedule",
|
||||
"status", "email", "last_ping", "n_pings")
|
||||
list_select_related = ("user", )
|
||||
list_filter = ("status", OwnershipListFilter, "kind", "last_ping")
|
||||
actions = ["send_alert"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(email=F("project__owner__email"))
|
||||
qs = qs.annotate(project_name=F("project__name"))
|
||||
return qs
|
||||
def email(self, obj):
|
||||
return obj.user.email if obj.user else None
|
||||
|
||||
@mark_safe
|
||||
def project_(self, obj):
|
||||
url = reverse("hc-checks", args=[obj.project.code])
|
||||
name = escape(obj.project_name or "Default")
|
||||
email = escape(obj.email)
|
||||
return f'{email} › <a href="{url}"">{name}</a>'
|
||||
|
||||
@mark_safe
|
||||
def name_tags(self, obj):
|
||||
url = reverse("hc-details", args=[obj.code])
|
||||
name = escape(obj.name or "unnamed")
|
||||
if not obj.tags:
|
||||
return obj.name
|
||||
|
||||
s = f'<a href="{url}"">{name}</a>'
|
||||
for tag in obj.tags_list():
|
||||
s += " <span>%s</span>" % escape(tag)
|
||||
return "%s [%s]" % (obj.name, obj.tags)
|
||||
|
||||
return s
|
||||
|
||||
@admin.display(description="Schedule")
|
||||
def timeout_schedule(self, obj):
|
||||
if obj.kind == "simple":
|
||||
return format_duration(obj.timeout)
|
||||
@ -64,21 +53,27 @@ class ChecksAdmin(admin.ModelAdmin):
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
@admin.action(description="Send Alert")
|
||||
timeout_schedule.short_description = "Schedule"
|
||||
|
||||
def send_alert(self, request, qs):
|
||||
for check in qs:
|
||||
for channel in check.channel_set.all():
|
||||
channel.notify(check)
|
||||
check.send_alert()
|
||||
|
||||
self.message_user(request, "%d alert(s) sent" % qs.count())
|
||||
|
||||
send_alert.short_description = "Send Alert"
|
||||
|
||||
|
||||
class SchemeListFilter(admin.SimpleListFilter):
|
||||
title = "Scheme"
|
||||
parameter_name = "scheme"
|
||||
parameter_name = 'scheme'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (("http", "HTTP"), ("https", "HTTPS"), ("email", "Email"))
|
||||
return (
|
||||
('http', "HTTP"),
|
||||
('https', "HTTPS"),
|
||||
('email', "Email"),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
@ -88,7 +83,7 @@ class SchemeListFilter(admin.SimpleListFilter):
|
||||
|
||||
class MethodListFilter(admin.SimpleListFilter):
|
||||
title = "Method"
|
||||
parameter_name = "method"
|
||||
parameter_name = 'method'
|
||||
methods = ["HEAD", "GET", "POST", "PUT", "DELETE"]
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
@ -100,20 +95,6 @@ class MethodListFilter(admin.SimpleListFilter):
|
||||
return queryset
|
||||
|
||||
|
||||
class KindListFilter(admin.SimpleListFilter):
|
||||
title = "Kind"
|
||||
parameter_name = "kind"
|
||||
kinds = ["start", "fail"]
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return zip(self.kinds, self.kinds)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
queryset = queryset.filter(kind=self.value())
|
||||
return queryset
|
||||
|
||||
|
||||
# Adapted from: https://djangosnippets.org/snippets/2593/
|
||||
class LargeTablePaginator(Paginator):
|
||||
""" Overrides the count method to get an estimate instead of actual count
|
||||
@ -123,10 +104,8 @@ class LargeTablePaginator(Paginator):
|
||||
def _get_estimate(self):
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
"SELECT reltuples FROM pg_class WHERE relname = %s",
|
||||
[self.object_list.query.model._meta.db_table],
|
||||
)
|
||||
cursor.execute("SELECT reltuples FROM pg_class WHERE relname = %s",
|
||||
[self.object_list.query.model._meta.db_table])
|
||||
return int(cursor.fetchone()[0])
|
||||
except:
|
||||
return 0
|
||||
@ -151,84 +130,70 @@ class LargeTablePaginator(Paginator):
|
||||
# (i.e. is of type list).
|
||||
self._count = len(self.object_list)
|
||||
return self._count
|
||||
|
||||
count = property(_get_count)
|
||||
|
||||
|
||||
@admin.register(Ping)
|
||||
class PingsAdmin(admin.ModelAdmin):
|
||||
search_fields = ("owner__name", "owner__code")
|
||||
readonly_fields = ("owner",)
|
||||
list_select_related = ("owner",)
|
||||
list_display = ("id", "created", "owner", "scheme", "method", "ua")
|
||||
list_filter = ("created", SchemeListFilter, MethodListFilter, KindListFilter)
|
||||
|
||||
search_fields = ("owner__name", "owner__code", "owner__user__email")
|
||||
list_select_related = ("owner", "owner__user")
|
||||
list_display = ("id", "created", "check_name", "email", "scheme", "method",
|
||||
"ua")
|
||||
list_filter = ("created", SchemeListFilter, MethodListFilter)
|
||||
paginator = LargeTablePaginator
|
||||
show_full_result_count = False
|
||||
|
||||
def check_name(self, obj):
|
||||
return obj.owner.name if obj.owner.name else obj.owner.code
|
||||
|
||||
def email(self, obj):
|
||||
return obj.owner.user.email if obj.owner.user else None
|
||||
|
||||
|
||||
@admin.register(Channel)
|
||||
class ChannelsAdmin(admin.ModelAdmin):
|
||||
class Media:
|
||||
css = {"all": ("css/admin/channels.css",)}
|
||||
css = {
|
||||
'all': ('css/admin/channels.css',)
|
||||
}
|
||||
|
||||
search_fields = ["value", "project__owner__email"]
|
||||
list_display = ("id", "transport", "name", "project_", "value", "ok")
|
||||
list_filter = ("kind",)
|
||||
raw_id_fields = ("project", "checks")
|
||||
search_fields = ["value", "user__email"]
|
||||
list_select_related = ("user", )
|
||||
list_display = ("id", "code", "email", "formatted_kind", "value",
|
||||
"num_notifications")
|
||||
list_filter = ("kind", )
|
||||
raw_id_fields = ("user", "checks", )
|
||||
|
||||
def email(self, obj):
|
||||
return obj.user.email if obj.user else None
|
||||
|
||||
@mark_safe
|
||||
def project_(self, obj):
|
||||
url = reverse("hc-checks", args=[obj.project_code])
|
||||
name = escape(obj.project_name or "Default")
|
||||
email = escape(obj.email)
|
||||
return f"{email} › <a href='{url}'>{name}</a>"
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(project_code=F("project__code"))
|
||||
qs = qs.annotate(project_name=F("project__name"))
|
||||
qs = qs.annotate(email=F("project__owner__email"))
|
||||
return qs
|
||||
|
||||
@mark_safe
|
||||
def transport(self, obj):
|
||||
note = ""
|
||||
def formatted_kind(self, obj):
|
||||
if obj.kind == "email" and not obj.email_verified:
|
||||
note = " (not verified)"
|
||||
return "Email <i>(unconfirmed)</i>"
|
||||
|
||||
return f'<span class="ic-{ obj.kind }"></span> {obj.kind}{note}'
|
||||
return obj.get_kind_display()
|
||||
|
||||
@admin.display(boolean=True)
|
||||
def ok(self, obj):
|
||||
return False if obj.last_error else True
|
||||
formatted_kind.short_description = "Kind"
|
||||
|
||||
def num_notifications(self, obj):
|
||||
return Notification.objects.filter(channel=obj).count()
|
||||
|
||||
num_notifications.short_description = "# Notifications"
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationsAdmin(admin.ModelAdmin):
|
||||
search_fields = ["owner__name", "owner__code", "channel__value", "error"]
|
||||
readonly_fields = ("owner",)
|
||||
search_fields = ["owner__name", "owner__code", "channel__value"]
|
||||
list_select_related = ("owner", "channel")
|
||||
list_display = (
|
||||
"id",
|
||||
"created",
|
||||
"check_status",
|
||||
"owner",
|
||||
"channel_kind",
|
||||
"channel_value",
|
||||
"error",
|
||||
)
|
||||
list_display = ("id", "created", "check_status", "check_name",
|
||||
"channel_kind", "channel_value")
|
||||
list_filter = ("created", "check_status", "channel__kind")
|
||||
raw_id_fields = ("channel",)
|
||||
|
||||
def check_name(self, obj):
|
||||
return obj.owner.name_then_code()
|
||||
|
||||
def channel_kind(self, obj):
|
||||
return obj.channel.kind
|
||||
|
||||
def channel_value(self, obj):
|
||||
return obj.channel.value
|
||||
|
||||
|
||||
@admin.register(Flip)
|
||||
class FlipsAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "created", "processed", "owner", "old_status", "new_status")
|
||||
raw_id_fields = ("owner",)
|
||||
|
@ -1,117 +1,72 @@
|
||||
import json
|
||||
import re
|
||||
from functools import wraps
|
||||
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from hc.accounts.models import Project
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import (HttpResponseBadRequest, HttpResponseForbidden,
|
||||
JsonResponse)
|
||||
from hc.lib.jsonschema import ValidationError, validate
|
||||
|
||||
|
||||
def error(msg, status=400):
|
||||
return JsonResponse({"error": msg}, status=status)
|
||||
RE_UUID = re.compile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
|
||||
|
||||
|
||||
def authorize(f):
|
||||
def uuid_or_400(f):
|
||||
@wraps(f)
|
||||
def wrapper(request, *args, **kwds):
|
||||
if not RE_UUID.match(args[0]):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
return f(request, *args, **kwds)
|
||||
return wrapper
|
||||
|
||||
|
||||
def make_error(msg):
|
||||
return JsonResponse({"error": msg}, status=400)
|
||||
|
||||
|
||||
def check_api_key(f):
|
||||
@wraps(f)
|
||||
def wrapper(request, *args, **kwds):
|
||||
request.json = {}
|
||||
if request.body:
|
||||
try:
|
||||
request.json = json.loads(request.body.decode("utf-8"))
|
||||
except ValueError:
|
||||
return make_error("could not parse request body")
|
||||
|
||||
if "HTTP_X_API_KEY" in request.META:
|
||||
api_key = request.META["HTTP_X_API_KEY"]
|
||||
else:
|
||||
api_key = str(request.json.get("api_key", ""))
|
||||
api_key = request.json.get("api_key", "")
|
||||
|
||||
if len(api_key) != 32:
|
||||
return error("missing api key", 401)
|
||||
if api_key == "":
|
||||
return make_error("wrong api_key")
|
||||
|
||||
try:
|
||||
request.project = Project.objects.get(api_key=api_key)
|
||||
except Project.DoesNotExist:
|
||||
return error("wrong api key", 401)
|
||||
request.user = User.objects.get(profile__api_key=api_key)
|
||||
except User.DoesNotExist:
|
||||
return HttpResponseForbidden()
|
||||
|
||||
request.readonly = False
|
||||
return f(request, *args, **kwds)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def authorize_read(f):
|
||||
@wraps(f)
|
||||
def wrapper(request, *args, **kwds):
|
||||
if "HTTP_X_API_KEY" in request.META:
|
||||
api_key = request.META["HTTP_X_API_KEY"]
|
||||
else:
|
||||
api_key = str(request.json.get("api_key", ""))
|
||||
def validate_json(schema):
|
||||
""" Validate request.json contents against `schema`.
|
||||
|
||||
if len(api_key) != 32:
|
||||
return error("missing api key", 401)
|
||||
|
||||
write_key_match = Q(api_key=api_key)
|
||||
read_key_match = Q(api_key_readonly=api_key)
|
||||
try:
|
||||
request.project = Project.objects.get(write_key_match | read_key_match)
|
||||
except Project.DoesNotExist:
|
||||
return error("wrong api key", 401)
|
||||
|
||||
request.readonly = api_key == request.project.api_key_readonly
|
||||
return f(request, *args, **kwds)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def validate_json(schema=None):
|
||||
""" Parse request json and validate it against `schema`.
|
||||
|
||||
Put the parsed result in `request.json`.
|
||||
If schema is None then only parse and don't validate.
|
||||
Supports a limited subset of JSON schema spec.
|
||||
Supports a tiny subset of JSON schema spec.
|
||||
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapper(request, *args, **kwds):
|
||||
if request.body:
|
||||
try:
|
||||
request.json = json.loads(request.body.decode())
|
||||
except ValueError:
|
||||
return error("could not parse request body")
|
||||
else:
|
||||
request.json = {}
|
||||
|
||||
if schema:
|
||||
try:
|
||||
validate(request.json, schema)
|
||||
except ValidationError as e:
|
||||
return error("json validation error: %s" % e)
|
||||
try:
|
||||
validate(request.json, schema)
|
||||
except ValidationError as e:
|
||||
return make_error("json validation error: %s" % e)
|
||||
|
||||
return f(request, *args, **kwds)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def cors(*methods):
|
||||
methods = set(methods)
|
||||
methods.add("OPTIONS")
|
||||
methods_str = ", ".join(methods)
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapper(request, *args, **kwds):
|
||||
if request.method == "OPTIONS":
|
||||
# Handle OPTIONS here
|
||||
response = HttpResponse(status=204)
|
||||
elif request.method in methods:
|
||||
response = f(request, *args, **kwds)
|
||||
else:
|
||||
response = HttpResponse(status=405)
|
||||
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
response["Access-Control-Allow-Headers"] = "X-Api-Key"
|
||||
response["Access-Control-Allow-Methods"] = methods_str
|
||||
response["Access-Control-Max-Age"] = "600"
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user