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__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.coverage
|
.coverage
|
||||||
.env
|
|
||||||
hc.sqlite
|
hc.sqlite
|
||||||
hc/local_settings.py
|
hc/local_settings.py
|
||||||
static-collected
|
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.
|
All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
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)
|
[](https://coveralls.io/github/healthchecks/healthchecks?branch=master)
|
||||||
|
|
||||||
|
|
||||||
@ -14,133 +14,161 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Healthchecks is a cron job monitoring service. It listens for HTTP requests
|
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.
|
||||||
and email messages ("pings") from your cron jobs and scheduled tasks ("checks").
|
|
||||||
When a ping does not arrive on time, Healthchecks sends out alerts.
|
|
||||||
|
|
||||||
Healthchecks comes with a web dashboard, API, 25+ integrations for
|
It is live here: [http://healthchecks.io/](http://healthchecks.io/)
|
||||||
delivering notifications, monthly email reports, WebAuthn 2FA support,
|
|
||||||
team management features: projects, team members, read-only access.
|
|
||||||
|
|
||||||
The building blocks are:
|
The building blocks are:
|
||||||
|
|
||||||
* Python 3.6+
|
* Python 2 or Python 3
|
||||||
* Django 3
|
* Django 1.11
|
||||||
* PostgreSQL or MySQL
|
* 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
|
## 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):
|
* prepare directory for project code and virtualenv:
|
||||||
|
|
||||||
$ sudo apt-get update
|
|
||||||
$ sudo apt-get install -y gcc python3-dev python3-venv libpq-dev
|
|
||||||
|
|
||||||
* Prepare directory for project code and virtualenv. Feel free to use a
|
|
||||||
different location:
|
|
||||||
|
|
||||||
$ mkdir -p ~/webapps
|
$ mkdir -p ~/webapps
|
||||||
$ cd ~/webapps
|
$ cd ~/webapps
|
||||||
|
|
||||||
* Prepare virtual environment
|
* prepare virtual environment
|
||||||
(with virtualenv you get pip, we'll use it soon to install requirements):
|
(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
|
$ 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
|
$ git clone https://github.com/healthchecks/healthchecks.git
|
||||||
|
|
||||||
* Install requirements (Django, ...) into virtualenv:
|
* install requirements (Django, ...) into virtualenv:
|
||||||
|
|
||||||
$ pip install -r healthchecks/requirements.txt
|
$ 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
|
$ cd ~/webapps/healthchecks
|
||||||
$ ./manage.py migrate
|
$ ./manage.py migrate
|
||||||
$ ./manage.py createsuperuser
|
$ ./manage.py createsuperuser
|
||||||
|
|
||||||
With the default configuration, Healthchecks stores data in a SQLite file
|
* run development server:
|
||||||
`hc.sqlite` in the checkout directory (`~/webapps/healthchecks`).
|
|
||||||
|
|
||||||
To use PostgreSQL or MySQL, see the section **Database Configuration** section
|
|
||||||
below.
|
|
||||||
|
|
||||||
* Run tests:
|
|
||||||
|
|
||||||
$ ./manage.py test
|
|
||||||
|
|
||||||
* Run development server:
|
|
||||||
|
|
||||||
$ ./manage.py runserver
|
$ ./manage.py runserver
|
||||||
|
|
||||||
The site should now be running at `http://localhost:8000`.
|
The site should now be running at `http://localhost:8080`
|
||||||
To access Django administration site, log in as a superuser, then
|
To log into Django administration site as a super user,
|
||||||
visit `http://localhost:8000/admin/`
|
visit `http://localhost:8080/admin`
|
||||||
|
|
||||||
## Configuration
|
## 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
|
```python
|
||||||
view and modify user accounts, projects, checks, integrations etc. To access it,
|
SITE_ROOT = "https://my-monitoring-project.com"
|
||||||
|
```
|
||||||
|
|
||||||
* if you haven't already, create a superuser account: `./manage.py createsuperuser`
|
`SITE_NAME` has the default value of "Mychecks" and is used throughout
|
||||||
* log into the site using superuser credentials
|
the templates. Replace it with your own name to personalize your installation.
|
||||||
* in the top navigation, "Account" dropdown, select "Site Administration"
|
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
|
## Sending Emails
|
||||||
|
|
||||||
Healthchecks must be able to send email messages, so it can send out login
|
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
|
links and alerts to users. Put your SMTP server configuration in
|
||||||
environment variables:
|
`hc/local_settings.py` like so:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
EMAIL_HOST = "your-smtp-server-here.com"
|
EMAIL_HOST = "your-smtp-server-here.com"
|
||||||
EMAIL_PORT = 587
|
EMAIL_PORT = 587
|
||||||
EMAIL_HOST_USER = "smtp-username"
|
EMAIL_HOST_USER = "username"
|
||||||
EMAIL_HOST_PASSWORD = "smtp-password"
|
EMAIL_HOST_PASSWORD = "password"
|
||||||
EMAIL_USE_TLS = True
|
EMAIL_USE_TLS = True
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information, have a look at Django documentation,
|
For more information, have a look at Django documentation,
|
||||||
[Sending Email](https://docs.djangoproject.com/en/1.10/topics/email/) section.
|
[Sending Email](https://docs.djangoproject.com/en/1.10/topics/email/) section.
|
||||||
|
|
||||||
## Receiving Emails
|
|
||||||
|
|
||||||
Healthchecks comes with a `smtpd` management command, which starts up a
|
|
||||||
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
|
## Sending Status Notifications
|
||||||
|
|
||||||
healtchecks comes with a `sendalerts` management command, which continuously
|
healtchecks comes with a `sendalerts` management command, which continuously
|
||||||
@ -155,7 +183,7 @@ manager like [supervisor](http://supervisord.org/) or systemd.
|
|||||||
|
|
||||||
## Database Cleanup
|
## 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
|
decide to prune old data: inactive user accounts, old checks not assigned
|
||||||
to users, records of outgoing email messages and records of received pings.
|
to users, records of outgoing email messages and records of received pings.
|
||||||
There are separate Django management commands for each task:
|
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
|
$ ./manage.py prunepings
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: 100 is the default value but you can configure a different
|
* Remove checks older than 2 hours that are not assigned to users. Such
|
||||||
limit per-user. To do that, go to the
|
checks are by-products of random visitors and robots loading the welcome
|
||||||
Administration Panel, look up user's **Profile** and modify its
|
page and never setting up an account:
|
||||||
"Ping log limit" field.
|
|
||||||
|
```
|
||||||
|
$ ./manage.py prunechecks
|
||||||
|
```
|
||||||
|
|
||||||
* Remove old records of sent notifications. For each check, remove
|
* Remove old records of sent notifications. For each check, remove
|
||||||
notifications that are older than the oldest stored ping for same check.
|
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:
|
* 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.
|
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
|
Assume the user doesn't intend to use the account any more and would
|
||||||
probably *want* it removed.
|
probably *want* it removed.
|
||||||
|
|
||||||
@ -190,80 +221,13 @@ There are separate Django management commands for each task:
|
|||||||
$ ./manage.py pruneusers
|
$ ./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
|
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.
|
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
|
In a production setup, you should also have regular, automated database
|
||||||
backups set up.
|
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
|
## 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
|
### Discord
|
||||||
|
|
||||||
To enable Discord integration, you will need to:
|
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
|
`SITE_ROOT/integrations/add_discord/`. For example, if you are running a
|
||||||
development server on `localhost:8000` then the redirect URI would be
|
development server on `localhost:8000` then the redirect URI would be
|
||||||
`http://localhost:8000/integrations/add_discord/`
|
`http://localhost:8000/integrations/add_discord/`
|
||||||
* Look up your Discord app's Client ID and Client Secret. Put them
|
* Look up your Discord app's Client ID and Client Secret. Add them
|
||||||
in `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET` environment
|
to your `hc/local_settings.py` file as `DISCORD_CLIENT_ID` and
|
||||||
variables.
|
`DISCORD_CLIENT_SECRET` fields.
|
||||||
|
|
||||||
|
|
||||||
### Pushover
|
### Pushover
|
||||||
|
|
||||||
Pushover integration works by creating an application on Pushover.net which
|
To enable Pushover integration, you will need to:
|
||||||
is then subscribed to by Healthchecks users. The registration workflow is as follows:
|
|
||||||
|
|
||||||
* On Healthchecks, the user adds a "Pushover" integration to a project
|
|
||||||
* Healthchecks redirects user's browser to a Pushover.net subscription page
|
|
||||||
* User approves adding the Healthchecks subscription to their Pushover account
|
|
||||||
* Pushover.net HTTP redirects back to Healthchecks with a subscription token
|
|
||||||
* Healthchecks saves the subscription token and uses it for sending Pushover
|
|
||||||
notifications
|
|
||||||
|
|
||||||
To enable the Pushover integration, you will need to:
|
|
||||||
|
|
||||||
* Register a new application on Pushover via https://pushover.net/apps/build.
|
|
||||||
* Within the Pushover 'application' configuration, enable subscriptions.
|
|
||||||
Make sure the subscription type is set to "URL". Also make sure the redirect
|
|
||||||
URL is configured to point back to the root of the Healthchecks instance
|
|
||||||
(e.g., `http://healthchecks.example.com/`).
|
|
||||||
* Put the Pushover application API Token and the Pushover subscription URL in
|
|
||||||
`PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL` environment
|
|
||||||
variables. The Pushover subscription URL should look similar to
|
|
||||||
`https://pushover.net/subscribe/yourAppName-randomAlphaNumericData`.
|
|
||||||
|
|
||||||
### Signal
|
|
||||||
|
|
||||||
Healthchecks uses [signal-cli](https://github.com/AsamK/signal-cli) to send Signal
|
|
||||||
notifications. Healthcecks interacts with signal-cli over DBus.
|
|
||||||
|
|
||||||
To enable the Signal integration:
|
|
||||||
|
|
||||||
* Set up and configure signal-cli to listen on DBus system bus ([instructions](https://github.com/AsamK/signal-cli/wiki/DBus-service)).
|
|
||||||
Make sure you can send test messages from command line, using the `dbus-send`
|
|
||||||
example given in the signal-cli instructions.
|
|
||||||
* Set the `SIGNAL_CLI_ENABLED` environment variable to `True`.
|
|
||||||
|
|
||||||
|
* register a new application on https://pushover.net/apps/build
|
||||||
|
* enable subscriptions in your application and make sure to enable the URL
|
||||||
|
subscription type
|
||||||
|
* add the application token and subscription URL to `hc/local_settings.py`, as
|
||||||
|
`PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL`
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|
||||||
* Create a Telegram bot by talking to the
|
* Create a Telegram bot by talking to the
|
||||||
[BotFather](https://core.telegram.org/bots#6-botfather). Set the bot's name,
|
[BotFather](https://core.telegram.org/bots#6-botfather). Set the bot's name,
|
||||||
description, user picture, and add a "/start" command.
|
description, user picture, and add a "/start" command.
|
||||||
* After creating the bot you will have the bot's name and token. Put them
|
* After creating the bot you will have the bot's name and token. Add them
|
||||||
in `TELEGRAM_BOT_NAME` and `TELEGRAM_TOKEN` environment variables.
|
to your `hc/local_settings.py` file as `TELEGRAM_BOT_NAME` and
|
||||||
|
`TELEGRAM_TOKEN` fields.
|
||||||
* Run `settelegramwebhook` management command. This command tells Telegram
|
* Run `settelegramwebhook` management command. This command tells Telegram
|
||||||
where to forward channel messages by invoking Telegram's
|
where to forward channel messages by invoking Telegram's
|
||||||
[setWebhook](https://core.telegram.org/bots/api#setwebhook) API call:
|
[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://"
|
For this to work, your `SITE_ROOT` needs to be correct and use "https://"
|
||||||
scheme.
|
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 import admin
|
||||||
from django.contrib.auth import login as auth_login
|
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Count, F
|
from django.db.models import Count
|
||||||
from django.shortcuts import redirect
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import escape
|
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from hc.accounts.models import Credential, Profile, Project
|
from hc.accounts.models import Profile
|
||||||
|
from hc.api.models import Channel, Check
|
||||||
|
|
||||||
@mark_safe
|
|
||||||
def _format_usage(num_checks, num_channels):
|
|
||||||
result = ""
|
|
||||||
|
|
||||||
if num_checks == 0:
|
|
||||||
result += "0 checks, "
|
|
||||||
elif num_checks == 1:
|
|
||||||
result += "1 check, "
|
|
||||||
else:
|
|
||||||
result += f"<strong>{num_checks} checks</strong>, "
|
|
||||||
|
|
||||||
if num_channels == 0:
|
|
||||||
result += "0 channels"
|
|
||||||
elif num_channels == 1:
|
|
||||||
result += "1 channel"
|
|
||||||
else:
|
|
||||||
result += f"<strong>{num_channels} channels</strong>"
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class Fieldset:
|
class Fieldset:
|
||||||
@ -43,200 +20,110 @@ class Fieldset:
|
|||||||
|
|
||||||
class ProfileFieldset(Fieldset):
|
class ProfileFieldset(Fieldset):
|
||||||
name = "User Profile"
|
name = "User Profile"
|
||||||
fields = (
|
fields = ("email", "api_key", "current_team", "reports_allowed",
|
||||||
"email",
|
"next_report_date", "nag_period", "next_nag_date",
|
||||||
"reports",
|
"token", "sort")
|
||||||
"tz",
|
|
||||||
"theme",
|
|
||||||
"next_report_date",
|
|
||||||
"nag_period",
|
|
||||||
"next_nag_date",
|
|
||||||
"deletion_notice_date",
|
|
||||||
"token",
|
|
||||||
"sort",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TeamFieldset(Fieldset):
|
class TeamFieldset(Fieldset):
|
||||||
name = "Team"
|
name = "Team"
|
||||||
fields = (
|
fields = ("team_name", "team_limit", "check_limit",
|
||||||
"team_limit",
|
"ping_log_limit", "sms_limit", "sms_sent", "last_sms_date",
|
||||||
"check_limit",
|
"bill_to")
|
||||||
"ping_log_limit",
|
|
||||||
"sms_limit",
|
|
||||||
"sms_sent",
|
|
||||||
"last_sms_date",
|
|
||||||
"call_limit",
|
|
||||||
"calls_sent",
|
|
||||||
"last_call_date",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NumChecksFilter(admin.SimpleListFilter):
|
|
||||||
title = "check count"
|
|
||||||
|
|
||||||
parameter_name = "num_checks"
|
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
|
||||||
return (
|
|
||||||
("10", "more than 10"),
|
|
||||||
("20", "more than 20"),
|
|
||||||
("50", "more than 50"),
|
|
||||||
("100", "more than 100"),
|
|
||||||
("500", "more than 500"),
|
|
||||||
("1000", "more than 1000"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def queryset(self, request, queryset):
|
|
||||||
if not self.value():
|
|
||||||
return
|
|
||||||
|
|
||||||
value = int(self.value())
|
|
||||||
return queryset.filter(num_checks__gt=value)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Profile)
|
@admin.register(Profile)
|
||||||
class ProfileAdmin(admin.ModelAdmin):
|
class ProfileAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
css = {"all": ("css/admin/profiles.css",)}
|
css = {
|
||||||
|
'all': ('css/admin/profiles.css',)
|
||||||
|
}
|
||||||
|
|
||||||
readonly_fields = ("user", "email")
|
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"]
|
search_fields = ["id", "user__email"]
|
||||||
list_per_page = 30
|
list_filter = ("team_limit", "reports_allowed",
|
||||||
list_select_related = ("user",)
|
"check_limit", "next_report_date")
|
||||||
list_display = (
|
|
||||||
"id",
|
|
||||||
"email",
|
|
||||||
"checks",
|
|
||||||
"date_joined",
|
|
||||||
"last_active_date",
|
|
||||||
"projects",
|
|
||||||
"invited",
|
|
||||||
"sms",
|
|
||||||
"reports",
|
|
||||||
)
|
|
||||||
list_filter = (
|
|
||||||
"user__date_joined",
|
|
||||||
"last_active_date",
|
|
||||||
"reports",
|
|
||||||
"check_limit",
|
|
||||||
NumChecksFilter,
|
|
||||||
"theme",
|
|
||||||
)
|
|
||||||
actions = ("login",)
|
|
||||||
|
|
||||||
fieldsets = (ProfileFieldset.tuple(), TeamFieldset.tuple())
|
fieldsets = (ProfileFieldset.tuple(), TeamFieldset.tuple())
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super(ProfileAdmin, self).get_queryset(request)
|
qs = super(ProfileAdmin, self).get_queryset(request)
|
||||||
qs = qs.prefetch_related("user__project_set")
|
qs = qs.annotate(Count("member", distinct=True))
|
||||||
qs = qs.annotate(num_members=Count("user__project__member", distinct=True))
|
qs = qs.annotate(Count("user__check", distinct=True))
|
||||||
qs = qs.annotate(num_checks=Count("user__project__check", distinct=True))
|
|
||||||
qs = qs.annotate(plan=F("user__subscription__plan_name"))
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@mark_safe
|
@mark_safe
|
||||||
def email(self, obj):
|
def users(self, obj):
|
||||||
s = escape(obj.user.email)
|
if obj.member__count == 0:
|
||||||
if obj.plan:
|
return obj.user.email
|
||||||
s = "%s <span>%s</span>" % (s, obj.plan)
|
else:
|
||||||
|
return render_to_string("admin/profile_list_team.html", {
|
||||||
return s
|
"profile": obj
|
||||||
|
})
|
||||||
def date_joined(self, obj):
|
|
||||||
return obj.user.date_joined
|
|
||||||
|
|
||||||
@mark_safe
|
|
||||||
def projects(self, obj):
|
|
||||||
return render_to_string("admin/profile_list_projects.html", {"profile": obj})
|
|
||||||
|
|
||||||
@mark_safe
|
@mark_safe
|
||||||
def checks(self, obj):
|
def checks(self, obj):
|
||||||
s = "%d of %d" % (obj.num_checks, obj.check_limit)
|
num_checks = obj.user__check__count
|
||||||
if obj.num_checks > 1:
|
pct = 100 * num_checks / max(obj.check_limit, 1)
|
||||||
s = "<b>%s</b>" % s
|
pct = min(100, int(pct))
|
||||||
return s
|
|
||||||
|
return """
|
||||||
|
<span class="bar"><span style="width: %dpx"></span></span>
|
||||||
|
%d of %d
|
||||||
|
""" % (pct, num_checks, obj.check_limit)
|
||||||
|
|
||||||
def invited(self, obj):
|
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):
|
def sms(self, obj):
|
||||||
return "%d of %d" % (obj.sms_sent, obj.sms_limit)
|
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):
|
def email(self, obj):
|
||||||
return obj.owner.email
|
return obj.user.email
|
||||||
|
|
||||||
def usage(self, obj):
|
|
||||||
return _format_usage(obj.num_checks, obj.num_channels)
|
|
||||||
|
|
||||||
@mark_safe
|
|
||||||
def switch(self, obj):
|
|
||||||
url = reverse("hc-checks", args=[obj.code])
|
|
||||||
return "<a href='%s'>Show Checks</a>" % url
|
|
||||||
|
|
||||||
|
|
||||||
class HcUserAdmin(UserAdmin):
|
class HcUserAdmin(UserAdmin):
|
||||||
actions = ["send_report", "send_nag"]
|
actions = ["send_report"]
|
||||||
list_display = (
|
list_display = ('id', 'email', 'date_joined', 'engagement',
|
||||||
"id",
|
'is_staff', 'checks')
|
||||||
"email",
|
|
||||||
"usage",
|
|
||||||
"date_joined",
|
|
||||||
"last_login",
|
|
||||||
"is_staff",
|
|
||||||
)
|
|
||||||
|
|
||||||
list_display_links = ("id", "email")
|
|
||||||
list_filter = ("last_login", "date_joined", "is_staff", "is_active")
|
list_filter = ("last_login", "date_joined", "is_staff", "is_active")
|
||||||
|
|
||||||
ordering = ["-id"]
|
ordering = ["-id"]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def engagement(self, user):
|
||||||
qs = super().get_queryset(request)
|
result = ""
|
||||||
qs = qs.annotate(num_checks=Count("project__check", distinct=True))
|
num_checks = Check.objects.filter(user=user).count()
|
||||||
qs = qs.annotate(num_channels=Count("project__channel", distinct=True))
|
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
|
if num_channels == 0:
|
||||||
def usage(self, user):
|
result += "0 channels"
|
||||||
return _format_usage(user.num_checks, user.num_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):
|
def send_report(self, request, qs):
|
||||||
for user in qs:
|
for user in qs:
|
||||||
@ -244,22 +131,6 @@ class HcUserAdmin(UserAdmin):
|
|||||||
|
|
||||||
self.message_user(request, "%d email(s) sent" % qs.count())
|
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.unregister(User)
|
||||||
admin.site.register(User, HcUserAdmin)
|
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.contrib.auth.models import User
|
||||||
from django.conf import settings
|
|
||||||
from hc.accounts.models import Profile
|
from hc.accounts.models import Profile
|
||||||
from hc.accounts.views import _make_user
|
|
||||||
|
|
||||||
|
|
||||||
class BasicBackend(object):
|
class BasicBackend(object):
|
||||||
|
|
||||||
def get_user(self, user_id):
|
def get_user(self, user_id):
|
||||||
try:
|
try:
|
||||||
q = User.objects.select_related("profile")
|
return User.objects.select_related("profile").get(pk=user_id)
|
||||||
|
|
||||||
return q.get(pk=user_id)
|
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Authenticate against the token in user's profile.
|
# Authenticate against the token in user's profile.
|
||||||
class ProfileBackend(BasicBackend):
|
class ProfileBackend(BasicBackend):
|
||||||
|
|
||||||
def authenticate(self, request=None, username=None, token=None):
|
def authenticate(self, request=None, username=None, token=None):
|
||||||
try:
|
try:
|
||||||
profiles = Profile.objects.select_related("user")
|
profiles = Profile.objects.select_related("user")
|
||||||
@ -30,6 +28,7 @@ class ProfileBackend(BasicBackend):
|
|||||||
|
|
||||||
|
|
||||||
class EmailBackend(BasicBackend):
|
class EmailBackend(BasicBackend):
|
||||||
|
|
||||||
def authenticate(self, request=None, username=None, password=None):
|
def authenticate(self, request=None, username=None, password=None):
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(email=username)
|
user = User.objects.get(email=username)
|
||||||
@ -38,31 +37,3 @@ class EmailBackend(BasicBackend):
|
|||||||
|
|
||||||
if user.check_password(password):
|
if user.check_password(password):
|
||||||
return user
|
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 datetime import timedelta as td
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.contrib.auth import authenticate
|
|
||||||
from django.contrib.auth.models import User
|
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):
|
class LowercaseEmailField(forms.EmailField):
|
||||||
|
|
||||||
def clean(self, value):
|
def clean(self, value):
|
||||||
value = super(LowercaseEmailField, self).clean(value)
|
value = super(LowercaseEmailField, self).clean(value)
|
||||||
return value.lower()
|
return value.lower()
|
||||||
|
|
||||||
|
|
||||||
class Base64Field(forms.CharField):
|
class EmailPasswordForm(forms.Form):
|
||||||
def to_python(self, value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return base64.b64decode(value.encode())
|
|
||||||
except binascii.Error:
|
|
||||||
raise ValidationError(message="Cannot decode base64")
|
|
||||||
|
|
||||||
|
|
||||||
class SignupForm(forms.Form):
|
|
||||||
# Call it "identity" instead of "email"
|
|
||||||
# to avoid some of the dumber bots
|
|
||||||
identity = LowercaseEmailField(
|
|
||||||
error_messages={"required": "Please enter your email address."}
|
|
||||||
)
|
|
||||||
tz = forms.CharField(required=False)
|
|
||||||
|
|
||||||
def clean_identity(self):
|
|
||||||
v = self.cleaned_data["identity"]
|
|
||||||
if len(v) > 254:
|
|
||||||
raise forms.ValidationError("Address is too long.")
|
|
||||||
|
|
||||||
if User.objects.filter(email=v).exists():
|
|
||||||
raise forms.ValidationError(
|
|
||||||
"An account with this email address already exists."
|
|
||||||
)
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
def clean_tz(self):
|
|
||||||
# Declare tz as "clean" only if we can find it in pytz.all_timezones
|
|
||||||
if self.cleaned_data["tz"] in pytz.all_timezones:
|
|
||||||
return self.cleaned_data["tz"]
|
|
||||||
|
|
||||||
# Otherwise, return None, and *don't* throw a validation exception:
|
|
||||||
# If user's browser reports a timezone we don't recognize, we
|
|
||||||
# should ignore the timezone but still save the rest of the form.
|
|
||||||
|
|
||||||
|
|
||||||
class EmailLoginForm(forms.Form):
|
|
||||||
# Call it "identity" instead of "email"
|
|
||||||
# to avoid some of the dumber bots
|
|
||||||
identity = LowercaseEmailField()
|
|
||||||
|
|
||||||
def clean_identity(self):
|
|
||||||
v = self.cleaned_data["identity"]
|
|
||||||
if not TokenBucket.authorize_login_email(v):
|
|
||||||
raise forms.ValidationError("Too many attempts, please try later.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.user = User.objects.get(email=v)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise forms.ValidationError("Unknown email address.")
|
|
||||||
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordLoginForm(forms.Form):
|
|
||||||
email = LowercaseEmailField()
|
email = LowercaseEmailField()
|
||||||
password = forms.CharField()
|
password = forms.CharField(required=False)
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
username = self.cleaned_data.get("email")
|
|
||||||
password = self.cleaned_data.get("password")
|
|
||||||
|
|
||||||
if username and password:
|
|
||||||
if not TokenBucket.authorize_login_password(username):
|
|
||||||
raise forms.ValidationError("Too many attempts, please try later.")
|
|
||||||
|
|
||||||
self.user = authenticate(username=username, password=password)
|
|
||||||
if self.user is None or not self.user.is_active:
|
|
||||||
raise forms.ValidationError("Incorrect email or password.")
|
|
||||||
|
|
||||||
return self.cleaned_data
|
|
||||||
|
|
||||||
|
|
||||||
class ReportSettingsForm(forms.Form):
|
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)
|
nag_period = forms.IntegerField(min_value=0, max_value=86400)
|
||||||
tz = forms.CharField()
|
|
||||||
|
|
||||||
def clean_nag_period(self):
|
def clean_nag_period(self):
|
||||||
seconds = self.cleaned_data["nag_period"]
|
seconds = self.cleaned_data["nag_period"]
|
||||||
@ -108,18 +27,9 @@ class ReportSettingsForm(forms.Form):
|
|||||||
|
|
||||||
return td(seconds=seconds)
|
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):
|
class SetPasswordForm(forms.Form):
|
||||||
password = forms.CharField(min_length=8)
|
password = forms.CharField()
|
||||||
|
|
||||||
|
|
||||||
class ChangeEmailForm(forms.Form):
|
class ChangeEmailForm(forms.Form):
|
||||||
@ -129,51 +39,18 @@ class ChangeEmailForm(forms.Form):
|
|||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
v = self.cleaned_data["email"]
|
v = self.cleaned_data["email"]
|
||||||
if User.objects.filter(email=v).exists():
|
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
|
return v
|
||||||
|
|
||||||
|
|
||||||
class InviteTeamMemberForm(forms.Form):
|
class InviteTeamMemberForm(forms.Form):
|
||||||
email = LowercaseEmailField(max_length=254)
|
email = LowercaseEmailField()
|
||||||
role = forms.ChoiceField(choices=Member.Role.choices)
|
|
||||||
|
|
||||||
|
|
||||||
class RemoveTeamMemberForm(forms.Form):
|
class RemoveTeamMemberForm(forms.Form):
|
||||||
email = LowercaseEmailField()
|
email = LowercaseEmailField()
|
||||||
|
|
||||||
|
|
||||||
class ProjectNameForm(forms.Form):
|
class TeamNameForm(forms.Form):
|
||||||
name = forms.CharField(max_length=60)
|
team_name = forms.CharField(max_length=200, required=True)
|
||||||
|
|
||||||
|
|
||||||
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"]
|
|
||||||
|
@ -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.contrib.auth.models import User
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db.models import Count, F
|
from django.db.models import Count
|
||||||
from django.utils.timezone import now
|
from django.utils import timezone
|
||||||
from hc.accounts.models import Profile
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = """Prune old, inactive user accounts.
|
help = """Prune old, inactive user accounts.
|
||||||
|
|
||||||
Conditions for removing an user account:
|
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.
|
to any team.
|
||||||
Use case: visitor types in their email at the website but
|
Use case: visitor types in their email at the website but
|
||||||
never follows through with login.
|
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):
|
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
|
# 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.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()
|
# Not logged in for 1 month, 0 checks, no team memberships
|
||||||
count = summary.get("auth.User", 0)
|
q = User.objects
|
||||||
self.stdout.write("Pruned %d never-logged-in user accounts." % count)
|
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
|
return "Done! Pruned %d user accounts." % (n1 + n2)
|
||||||
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!"
|
|
||||||
|
@ -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
|
from hc.accounts.models import Profile
|
||||||
|
|
||||||
|
|
||||||
@ -13,51 +9,11 @@ class TeamAccessMiddleware(object):
|
|||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return self.get_response(request)
|
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.profile = Profile.objects.for_user(request.user)
|
||||||
|
request.team = request.profile.team()
|
||||||
|
|
||||||
return self.get_response(request)
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Profile",
|
name='Profile',
|
||||||
fields=[
|
fields=[
|
||||||
(
|
('id', models.AutoField(auto_created=True, serialize=False, verbose_name='ID', primary_key=True)),
|
||||||
"id",
|
('next_report_date', models.DateTimeField(null=True, blank=True)),
|
||||||
models.AutoField(
|
('reports_allowed', models.BooleanField(default=True)),
|
||||||
auto_created=True,
|
('user', models.OneToOneField(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
|
||||||
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0001_initial")]
|
dependencies = [
|
||||||
|
('accounts', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="ping_log_limit",
|
name='ping_log_limit',
|
||||||
field=models.IntegerField(default=100),
|
field=models.IntegerField(default=100),
|
||||||
)
|
),
|
||||||
]
|
]
|
||||||
|
@ -7,12 +7,14 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0002_profile_ping_log_limit")]
|
dependencies = [
|
||||||
|
('accounts', '0002_profile_ping_log_limit'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="token",
|
name='token',
|
||||||
field=models.CharField(blank=True, max_length=128),
|
field=models.CharField(blank=True, max_length=128),
|
||||||
)
|
),
|
||||||
]
|
]
|
||||||
|
@ -7,12 +7,14 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0003_profile_token")]
|
dependencies = [
|
||||||
|
('accounts', '0003_profile_token'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="api_key",
|
name='api_key',
|
||||||
field=models.CharField(blank=True, max_length=128),
|
field=models.CharField(blank=True, max_length=128),
|
||||||
)
|
),
|
||||||
]
|
]
|
||||||
|
@ -11,46 +11,34 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
("accounts", "0004_profile_api_key"),
|
('accounts', '0004_profile_api_key'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Member",
|
name='Member',
|
||||||
fields=[
|
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(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="team_access_allowed",
|
name='team_access_allowed',
|
||||||
field=models.BooleanField(default=False),
|
field=models.BooleanField(default=False),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="team_name",
|
name='team_name',
|
||||||
field=models.CharField(blank=True, max_length=200),
|
field=models.CharField(blank=True, max_length=200),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="member",
|
model_name='member',
|
||||||
name="team",
|
name='team',
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Profile'),
|
||||||
on_delete=django.db.models.deletion.CASCADE, to="accounts.Profile"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="member",
|
model_name='member',
|
||||||
name="user",
|
name='user',
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||||
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0005_auto_20160509_0801")]
|
dependencies = [
|
||||||
|
('accounts', '0005_auto_20160509_0801'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="current_team",
|
name='current_team',
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.Profile'),
|
||||||
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0006_profile_current_team")]
|
dependencies = [
|
||||||
|
('accounts', '0006_profile_current_team'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="check_limit",
|
name='check_limit',
|
||||||
field=models.IntegerField(default=20),
|
field=models.IntegerField(default=20),
|
||||||
)
|
),
|
||||||
]
|
]
|
||||||
|
@ -7,10 +7,14 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0007_profile_check_limit")]
|
dependencies = [
|
||||||
|
('accounts', '0007_profile_check_limit'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0008_profile_bill_to")]
|
dependencies = [
|
||||||
|
('accounts', '0008_profile_bill_to'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="last_sms_date",
|
name='last_sms_date',
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
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(
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0009_auto_20170714_1734")]
|
dependencies = [
|
||||||
|
('accounts', '0009_auto_20170714_1734'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="team_limit",
|
name='team_limit',
|
||||||
field=models.IntegerField(default=2),
|
field=models.IntegerField(default=2),
|
||||||
)
|
),
|
||||||
]
|
]
|
||||||
|
@ -7,12 +7,14 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0010_profile_team_limit")]
|
dependencies = [
|
||||||
|
('accounts', '0010_profile_team_limit'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="sort",
|
name='sort',
|
||||||
field=models.CharField(default="created", max_length=20),
|
field=models.CharField(default='created', max_length=20),
|
||||||
)
|
),
|
||||||
]
|
]
|
||||||
|
@ -8,24 +8,19 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0011_profile_sort")]
|
dependencies = [
|
||||||
|
('accounts', '0011_profile_sort'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="nag_period",
|
name='nag_period',
|
||||||
field=models.DurationField(
|
field=models.DurationField(choices=[(datetime.timedelta(0), 'Disabled'), (datetime.timedelta(0, 3600), 'Hourly'), (datetime.timedelta(1), 'Daily')], default=datetime.timedelta(0)),
|
||||||
choices=[
|
|
||||||
(datetime.timedelta(0), "Disabled"),
|
|
||||||
(datetime.timedelta(0, 3600), "Hourly"),
|
|
||||||
(datetime.timedelta(1), "Daily"),
|
|
||||||
],
|
|
||||||
default=datetime.timedelta(0),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="profile",
|
model_name='profile',
|
||||||
name="next_nag_date",
|
name='next_nag_date',
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -7,8 +7,13 @@ from django.db import migrations
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0012_auto_20171014_1002")]
|
dependencies = [
|
||||||
|
('accounts', '0012_auto_20171014_1002'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0013_remove_profile_team_access_allowed")]
|
dependencies = [
|
||||||
|
('accounts', '0013_remove_profile_team_access_allowed'),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="member",
|
model_name='member',
|
||||||
name="user",
|
name='user',
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to=settings.AUTH_USER_MODEL),
|
||||||
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
|
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.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password, make_password
|
from django.contrib.auth.hashers import check_password, make_password
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.signing import TimestampSigner
|
from django.core.signing import TimestampSigner
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count, Q
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from fido2.ctap2 import AttestedCredentialData
|
|
||||||
from hc.lib import emails
|
from hc.lib import emails
|
||||||
from hc.lib.date import month_boundaries
|
|
||||||
import pytz
|
|
||||||
|
|
||||||
NO_NAG = timedelta()
|
NO_NAG = timedelta()
|
||||||
NAG_PERIODS = (
|
NAG_PERIODS = ((NO_NAG, "Disabled"),
|
||||||
(NO_NAG, "Disabled"),
|
(timedelta(hours=1), "Hourly"),
|
||||||
(timedelta(hours=1), "Hourly"),
|
(timedelta(days=1), "Daily"))
|
||||||
(timedelta(days=1), "Daily"),
|
|
||||||
)
|
|
||||||
|
|
||||||
REPORT_CHOICES = (("off", "Off"), ("weekly", "Weekly"), ("monthly", "Monthly"))
|
|
||||||
|
|
||||||
|
|
||||||
def month(dt):
|
def month(dt):
|
||||||
@ -42,7 +33,6 @@ class ProfileManager(models.Manager):
|
|||||||
# If not using payments, set high limits
|
# If not using payments, set high limits
|
||||||
profile.check_limit = 500
|
profile.check_limit = 500
|
||||||
profile.sms_limit = 500
|
profile.sms_limit = 500
|
||||||
profile.call_limit = 500
|
|
||||||
profile.team_limit = 500
|
profile.team_limit = 500
|
||||||
|
|
||||||
profile.save()
|
profile.save()
|
||||||
@ -50,37 +40,29 @@ class ProfileManager(models.Manager):
|
|||||||
|
|
||||||
|
|
||||||
class Profile(models.Model):
|
class Profile(models.Model):
|
||||||
|
# Owner:
|
||||||
user = models.OneToOneField(User, models.CASCADE, blank=True, null=True)
|
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)
|
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)
|
nag_period = models.DurationField(default=NO_NAG, choices=NAG_PERIODS)
|
||||||
next_nag_date = models.DateTimeField(null=True, blank=True)
|
next_nag_date = models.DateTimeField(null=True, blank=True)
|
||||||
ping_log_limit = models.IntegerField(default=100)
|
ping_log_limit = models.IntegerField(default=100)
|
||||||
check_limit = models.IntegerField(default=20)
|
check_limit = models.IntegerField(default=20)
|
||||||
token = models.CharField(max_length=128, blank=True)
|
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)
|
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)
|
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)
|
team_limit = models.IntegerField(default=2)
|
||||||
sort = models.CharField(max_length=20, default="created")
|
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()
|
objects = ProfileManager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Profile for %s" % self.user.email
|
return self.team_name or self.user.email
|
||||||
|
|
||||||
def notifications_url(self):
|
def notifications_url(self):
|
||||||
return settings.SITE_ROOT + reverse("hc-notifications")
|
return settings.SITE_ROOT + reverse("hc-notifications")
|
||||||
@ -91,8 +73,15 @@ class Profile(models.Model):
|
|||||||
path = reverse("hc-unsubscribe-reports", args=[signed_username])
|
path = reverse("hc-unsubscribe-reports", args=[signed_username])
|
||||||
return settings.SITE_ROOT + path
|
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):
|
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.token = make_password(token, salt)
|
||||||
self.save()
|
self.save()
|
||||||
return token
|
return token
|
||||||
@ -100,86 +89,52 @@ class Profile(models.Model):
|
|||||||
def check_token(self, token, salt):
|
def check_token(self, token, salt):
|
||||||
return salt in self.token and check_password(token, self.token)
|
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")
|
token = self.prepare_token("login")
|
||||||
path = reverse("hc-check-token", args=[self.user.username, token])
|
path = reverse("hc-check-token", args=[self.user.username, token])
|
||||||
if redirect_url:
|
|
||||||
path += "?next=%s" % redirect_url
|
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"button_text": "Sign In",
|
"button_text": "Log In",
|
||||||
"button_url": settings.SITE_ROOT + path,
|
"button_url": settings.SITE_ROOT + path,
|
||||||
"inviting_project": inviting_project,
|
"inviting_profile": inviting_profile
|
||||||
}
|
}
|
||||||
emails.login(self.user.email, ctx)
|
emails.login(self.user.email, ctx)
|
||||||
|
|
||||||
def send_transfer_request(self, project):
|
def send_set_password_link(self):
|
||||||
token = self.prepare_token("login")
|
token = self.prepare_token("set-password")
|
||||||
settings_path = reverse("hc-project-settings", args=[project.code])
|
path = reverse("hc-set-password", args=[token])
|
||||||
path = reverse("hc-check-token", args=[self.user.username, token])
|
|
||||||
path += "?next=%s" % settings_path
|
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"button_text": "Project Settings",
|
"button_text": "Set Password",
|
||||||
"button_url": settings.SITE_ROOT + path,
|
"button_url": settings.SITE_ROOT + path
|
||||||
"project": project,
|
|
||||||
}
|
}
|
||||||
emails.transfer_request(self.user.email, ctx)
|
emails.set_password(self.user.email, ctx)
|
||||||
|
|
||||||
def send_sms_limit_notice(self, transport):
|
def send_change_email_link(self):
|
||||||
ctx = {"transport": transport, "limit": self.sms_limit}
|
token = self.prepare_token("change-email")
|
||||||
if self.sms_limit != 500 and settings.USE_PAYMENTS:
|
path = reverse("hc-change-email", args=[token])
|
||||||
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
|
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):
|
def checks_from_all_teams(self):
|
||||||
ctx = {"limit": self.call_limit}
|
""" Return a queryset of checks from all teams we have access for. """
|
||||||
if self.call_limit != 500 and settings.USE_PAYMENTS:
|
|
||||||
ctx["url"] = settings.SITE_ROOT + reverse("hc-pricing")
|
|
||||||
|
|
||||||
emails.call_limit(self.user.email, ctx)
|
team_ids = set(self.user.memberships.values_list("team_id", flat=True))
|
||||||
|
team_ids.add(self.id)
|
||||||
def projects(self):
|
|
||||||
""" Return a queryset of all projects we have access to. """
|
|
||||||
|
|
||||||
is_owner = Q(owner_id=self.user_id)
|
|
||||||
is_member = Q(member__user_id=self.user_id)
|
|
||||||
q = Project.objects.filter(is_owner | is_member)
|
|
||||||
return q.distinct().order_by("name")
|
|
||||||
|
|
||||||
def annotated_projects(self):
|
|
||||||
""" Return all projects, annotated with 'n_down'. """
|
|
||||||
|
|
||||||
# Subquery for getting project ids
|
|
||||||
project_ids = self.projects().values("id")
|
|
||||||
|
|
||||||
# Main query with the n_down annotation.
|
|
||||||
# Must use the subquery, otherwise ORM gets confused by
|
|
||||||
# joins and group by's
|
|
||||||
q = Project.objects.filter(id__in=project_ids)
|
|
||||||
n_down = Count("check", filter=Q(check__status="down"))
|
|
||||||
q = q.annotate(n_down=n_down)
|
|
||||||
return q.order_by("name")
|
|
||||||
|
|
||||||
def checks_from_all_projects(self):
|
|
||||||
""" Return a queryset of checks from projects we have access to. """
|
|
||||||
|
|
||||||
project_ids = self.projects().values("id")
|
|
||||||
|
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
|
return Check.objects.filter(user__profile__id__in=team_ids)
|
||||||
return Check.objects.filter(project_id__in=project_ids)
|
|
||||||
|
|
||||||
def send_report(self, nag=False):
|
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?
|
# Is there at least one check that has received a ping?
|
||||||
result = checks.aggregate(models.Max("last_ping"))
|
if not checks.filter(last_ping__isnull=False).exists():
|
||||||
last_ping = result["last_ping__max"]
|
|
||||||
|
|
||||||
six_months_ago = timezone.now() - timedelta(days=180)
|
|
||||||
if last_ping is None or last_ping < six_months_ago:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Is there at least one check that is down?
|
# Is there at least one check that is down?
|
||||||
@ -187,42 +142,42 @@ class Profile(models.Model):
|
|||||||
if nag and num_down == 0:
|
if nag and num_down == 0:
|
||||||
return False
|
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.
|
# template.
|
||||||
checks = checks.select_related("project")
|
checks = checks.select_related("user", "user__profile")
|
||||||
checks = checks.order_by("project_id")
|
checks = checks.order_by("user_id")
|
||||||
# list() executes the query, to avoid DB access while
|
# list() executes the query, to avoid DB access while
|
||||||
# rendering the template
|
# rendering the template
|
||||||
checks = list(checks)
|
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 = {
|
ctx = {
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
"sort": self.sort,
|
"sort": self.sort,
|
||||||
"now": timezone.now(),
|
"now": timezone.now(),
|
||||||
"unsub_link": unsub_url,
|
"unsub_link": self.reports_unsub_url(),
|
||||||
"notifications_url": self.notifications_url(),
|
"notifications_url": self.notifications_url(),
|
||||||
"nag": nag,
|
"nag": nag,
|
||||||
"nag_period": self.nag_period.total_seconds(),
|
"nag_period": self.nag_period.total_seconds(),
|
||||||
"num_down": num_down,
|
"num_down": num_down
|
||||||
"month_boundaries": boundaries,
|
|
||||||
"monthly_or_weekly": self.reports,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emails.report(self.user.email, ctx, headers)
|
emails.report(self.user.email, ctx)
|
||||||
return True
|
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):
|
def sms_sent_this_month(self):
|
||||||
# IF last_sms_date was never set, we have not sent any messages yet.
|
# IF last_sms_date was never set, we have not sent any messages yet.
|
||||||
if not self.last_sms_date:
|
if not self.last_sms_date:
|
||||||
@ -246,211 +201,19 @@ class Profile(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def calls_sent_this_month(self):
|
def set_next_nag_date(self):
|
||||||
# IF last_call_date was never set, we have not made any phone calls yet.
|
""" Set next_nag_date for all members of this team. """
|
||||||
if not self.last_call_date:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# If last sent date is not from this month, we've made 0 calls this month.
|
is_owner = models.Q(id=self.id)
|
||||||
if month(timezone.now()) > month(self.last_call_date):
|
is_member = models.Q(user__memberships__team=self)
|
||||||
return 0
|
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
|
q.update(next_nag_date=timezone.now() + models.F("nag_period"))
|
||||||
|
|
||||||
def authorize_call(self):
|
|
||||||
""" If monthly limit not exceeded, increase counter and return True """
|
|
||||||
|
|
||||||
sent_this_month = self.calls_sent_this_month()
|
|
||||||
if sent_this_month >= self.call_limit:
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.calls_sent = sent_this_month + 1
|
|
||||||
self.last_call_date = timezone.now()
|
|
||||||
self.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def num_checks_used(self):
|
|
||||||
from hc.api.models import Check
|
|
||||||
|
|
||||||
return Check.objects.filter(project__owner_id=self.user_id).count()
|
|
||||||
|
|
||||||
def num_checks_available(self):
|
|
||||||
return self.check_limit - self.num_checks_used()
|
|
||||||
|
|
||||||
def can_accept(self, project):
|
|
||||||
return project.num_checks() <= self.num_checks_available()
|
|
||||||
|
|
||||||
def update_next_nag_date(self):
|
|
||||||
any_down = self.checks_from_all_projects().filter(status="down").exists()
|
|
||||||
if any_down and self.next_nag_date is None and self.nag_period:
|
|
||||||
self.next_nag_date = timezone.now() + self.nag_period
|
|
||||||
self.save(update_fields=["next_nag_date"])
|
|
||||||
elif not any_down and self.next_nag_date:
|
|
||||||
self.next_nag_date = None
|
|
||||||
self.save(update_fields=["next_nag_date"])
|
|
||||||
|
|
||||||
def choose_next_report_date(self):
|
|
||||||
""" Calculate the target date for the next monthly/weekly report.
|
|
||||||
|
|
||||||
Monthly reports should get sent on 1st of each month, between
|
|
||||||
9AM and 11AM in user's timezone.
|
|
||||||
|
|
||||||
Weekly reports should get sent on Mondays, between
|
|
||||||
9AM and 11AM in user's timezone.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.reports == "off":
|
|
||||||
return None
|
|
||||||
|
|
||||||
tz = pytz.timezone(self.tz)
|
|
||||||
dt = timezone.now().astimezone(tz)
|
|
||||||
dt = dt.replace(hour=9, minute=0) + timedelta(minutes=random.randrange(0, 120))
|
|
||||||
|
|
||||||
while True:
|
|
||||||
dt += timedelta(days=1)
|
|
||||||
if self.reports == "monthly" and dt.day == 1:
|
|
||||||
return dt
|
|
||||||
elif self.reports == "weekly" and dt.weekday() == 0:
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
class Project(models.Model):
|
|
||||||
code = models.UUIDField(default=uuid.uuid4, unique=True)
|
|
||||||
name = models.CharField(max_length=200, blank=True)
|
|
||||||
owner = models.ForeignKey(User, models.CASCADE)
|
|
||||||
api_key = models.CharField(max_length=128, blank=True, db_index=True)
|
|
||||||
api_key_readonly = models.CharField(max_length=128, blank=True, db_index=True)
|
|
||||||
badge_key = models.CharField(max_length=150, unique=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name or self.owner.email
|
|
||||||
|
|
||||||
@property
|
|
||||||
def owner_profile(self):
|
|
||||||
return Profile.objects.for_user(self.owner)
|
|
||||||
|
|
||||||
def num_checks(self):
|
|
||||||
return self.check_set.count()
|
|
||||||
|
|
||||||
def num_checks_available(self):
|
|
||||||
return self.owner_profile.num_checks_available()
|
|
||||||
|
|
||||||
def set_api_keys(self):
|
|
||||||
self.api_key = token_urlsafe(nbytes=24)
|
|
||||||
self.api_key_readonly = token_urlsafe(nbytes=24)
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def invite_suggestions(self):
|
|
||||||
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
|
|
||||||
q = q.exclude(memberships__project=self)
|
|
||||||
return q.distinct().order_by("email")
|
|
||||||
|
|
||||||
def can_invite_new_users(self):
|
|
||||||
q = User.objects.filter(memberships__project__owner_id=self.owner_id)
|
|
||||||
used = q.distinct().count()
|
|
||||||
return used < self.owner_profile.team_limit
|
|
||||||
|
|
||||||
def invite(self, user, role):
|
|
||||||
if Member.objects.filter(user=user, project=self).exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.owner_id == user.id:
|
|
||||||
return False
|
|
||||||
|
|
||||||
Member.objects.create(user=user, project=self, role=role)
|
|
||||||
checks_url = reverse("hc-checks", args=[self.code])
|
|
||||||
user.profile.send_instant_login_link(self, redirect_url=checks_url)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def update_next_nag_dates(self):
|
|
||||||
""" Update next_nag_date on profiles of all members of this project. """
|
|
||||||
|
|
||||||
is_owner = Q(user_id=self.owner_id)
|
|
||||||
is_member = Q(user__memberships__project=self)
|
|
||||||
q = Profile.objects.filter(is_owner | is_member).exclude(nag_period=NO_NAG)
|
|
||||||
|
|
||||||
for profile in q:
|
|
||||||
profile.update_next_nag_date()
|
|
||||||
|
|
||||||
def overall_status(self):
|
|
||||||
if not hasattr(self, "_overall_status"):
|
|
||||||
self._overall_status = "up"
|
|
||||||
for check in self.check_set.all():
|
|
||||||
check_status = check.get_status()
|
|
||||||
if check_status == "grace" and self._overall_status == "up":
|
|
||||||
self._overall_status = "grace"
|
|
||||||
elif check_status == "down":
|
|
||||||
self._overall_status = "down"
|
|
||||||
break
|
|
||||||
|
|
||||||
return self._overall_status
|
|
||||||
|
|
||||||
def get_n_down(self):
|
|
||||||
result = 0
|
|
||||||
for check in self.check_set.all():
|
|
||||||
if check.get_status() == "down":
|
|
||||||
result += 1
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def have_channel_issues(self):
|
|
||||||
errors = list(self.channel_set.values_list("last_error", flat=True))
|
|
||||||
|
|
||||||
# It's a problem if a project has no integrations at all
|
|
||||||
if len(errors) == 0:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# It's a problem if any integration has a logged error
|
|
||||||
return True if max(errors) else False
|
|
||||||
|
|
||||||
def transfer_request(self):
|
|
||||||
return self.member_set.filter(transfer_request_date__isnull=False).first()
|
|
||||||
|
|
||||||
def dashboard_url(self):
|
|
||||||
if not self.api_key_readonly:
|
|
||||||
return None
|
|
||||||
|
|
||||||
frag = urlencode({self.api_key_readonly: str(self)}, quote_via=quote)
|
|
||||||
return reverse("hc-dashboard") + "#" + frag
|
|
||||||
|
|
||||||
def checks_url(self):
|
|
||||||
return settings.SITE_ROOT + reverse("hc-checks", args=[self.code])
|
|
||||||
|
|
||||||
|
|
||||||
class Member(models.Model):
|
class Member(models.Model):
|
||||||
class Role(models.TextChoices):
|
team = models.ForeignKey(Profile, models.CASCADE)
|
||||||
READONLY = "r", "Read-only"
|
|
||||||
REGULAR = "w", "Member"
|
|
||||||
MANAGER = "m", "Manager"
|
|
||||||
|
|
||||||
user = models.ForeignKey(User, models.CASCADE, related_name="memberships")
|
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):
|
class AccountsAdminTestCase(BaseTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super(AccountsAdminTestCase, self).setUp()
|
||||||
|
|
||||||
self.alice.is_staff = True
|
self.alice.is_staff = True
|
||||||
self.alice.is_superuser = 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
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
class ChangeEmailTestCase(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):
|
def test_it_shows_form(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.profile.token = make_password("foo", "change-email")
|
||||||
self.set_sudo_flag()
|
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")
|
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.client.login(username="alice@example.org", password="password")
|
||||||
self.set_sudo_flag()
|
|
||||||
|
|
||||||
payload = {"email": "alice2@example.org"}
|
payload = {"email": "alice2@example.org"}
|
||||||
r = self.client.post("/accounts/change_email/", payload, follow=True)
|
self.client.post("/accounts/change_email/foo/", payload)
|
||||||
self.assertRedirects(r, "/accounts/change_email/done/")
|
|
||||||
self.assertContains(r, "Email Address Updated")
|
|
||||||
|
|
||||||
self.alice.refresh_from_db()
|
self.alice.refresh_from_db()
|
||||||
self.assertEqual(self.alice.email, "alice2@example.org")
|
self.assertEqual(self.alice.email, "alice2@example.org")
|
||||||
self.assertFalse(self.alice.has_usable_password())
|
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):
|
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.client.login(username="alice@example.org", password="password")
|
||||||
self.set_sudo_flag()
|
|
||||||
|
|
||||||
payload = {"email": "bob@example.org"}
|
payload = {"email": "bob@example.org"}
|
||||||
r = self.client.post("/accounts/change_email/", payload)
|
r = self.client.post("/accounts/change_email/foo/", payload)
|
||||||
self.assertContains(r, "bob@example.org is already registered")
|
self.assertContains(r, "bob@example.org is not available")
|
||||||
|
|
||||||
self.alice.refresh_from_db()
|
self.alice.refresh_from_db()
|
||||||
self.assertEqual(self.alice.email, "alice@example.org")
|
self.assertEqual(self.alice.email, "alice@example.org")
|
||||||
|
@ -1,24 +1,21 @@
|
|||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from hc.accounts.models import Credential
|
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
class CheckTokenTestCase(BaseTestCase):
|
class CheckTokenTestCase(BaseTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super(CheckTokenTestCase, self).setUp()
|
||||||
self.profile.token = make_password("secret-token", "login")
|
self.profile.token = make_password("secret-token", "login")
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
self.checks_url = "/projects/%s/checks/" % self.project.code
|
|
||||||
|
|
||||||
def test_it_shows_form(self):
|
def test_it_shows_form(self):
|
||||||
r = self.client.get("/accounts/check_token/alice/secret-token/")
|
r = self.client.get("/accounts/check_token/alice/secret-token/")
|
||||||
self.assertContains(r, "You are about to log in")
|
self.assertContains(r, "You are about to log in")
|
||||||
|
|
||||||
def test_it_redirects(self):
|
def test_it_redirects(self):
|
||||||
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
||||||
|
self.assertRedirects(r, "/checks/")
|
||||||
self.assertRedirects(r, self.checks_url)
|
|
||||||
|
|
||||||
# After login, token should be blank
|
# After login, token should be blank
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
@ -30,8 +27,7 @@ class CheckTokenTestCase(BaseTestCase):
|
|||||||
|
|
||||||
# Login again, when already authenticated
|
# Login again, when already authenticated
|
||||||
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
r = self.client.post("/accounts/check_token/alice/secret-token/")
|
||||||
|
self.assertRedirects(r, "/checks/")
|
||||||
self.assertRedirects(r, self.checks_url)
|
|
||||||
|
|
||||||
def test_it_redirects_bad_login(self):
|
def test_it_redirects_bad_login(self):
|
||||||
# Login with a bad token
|
# Login with a bad token
|
||||||
@ -39,28 +35,3 @@ class CheckTokenTestCase(BaseTestCase):
|
|||||||
r = self.client.post(url, follow=True)
|
r = self.client.post(url, follow=True)
|
||||||
self.assertRedirects(r, "/accounts/login/")
|
self.assertRedirects(r, "/accounts/login/")
|
||||||
self.assertContains(r, "incorrect or expired")
|
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 django.contrib.auth.models import User
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
from hc.payments.models import Subscription
|
from hc.payments.models import Subscription
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
|
|
||||||
class CloseAccountTestCase(BaseTestCase):
|
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/")
|
@patch("hc.payments.models.Subscription.cancel")
|
||||||
self.assertContains(r, "We have sent a confirmation code")
|
def test_it_works(self, mock_cancel):
|
||||||
|
Check.objects.create(user=self.alice, tags="foo a-B_1 baz@")
|
||||||
def test_it_shows_confirmation_form(self):
|
Subscription.objects.create(user=self.alice, subscription_id="123")
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
self.set_sudo_flag()
|
|
||||||
|
|
||||||
r = self.client.get("/accounts/close/")
|
|
||||||
self.assertContains(r, "Close Account?")
|
|
||||||
self.assertContains(r, "1 project")
|
|
||||||
self.assertContains(r, "0 checks")
|
|
||||||
|
|
||||||
@patch("hc.payments.models.braintree")
|
|
||||||
def test_it_works(self, mock_braintree):
|
|
||||||
Check.objects.create(project=self.project, tags="foo a-B_1 baz@")
|
|
||||||
Subscription.objects.create(
|
|
||||||
user=self.alice, subscription_id="123", customer_id="fake-customer-id"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
self.set_sudo_flag()
|
r = self.client.post("/accounts/close/")
|
||||||
|
self.assertEqual(r.status_code, 302)
|
||||||
payload = {"confirmation": "alice@example.org"}
|
|
||||||
r = self.client.post("/accounts/close/", payload)
|
|
||||||
self.assertRedirects(r, "/")
|
|
||||||
|
|
||||||
# Alice should be gone
|
# Alice should be gone
|
||||||
alices = User.objects.filter(username="alice")
|
alices = User.objects.filter(username="alice")
|
||||||
self.assertFalse(alices.exists())
|
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
|
# Check should be gone
|
||||||
self.assertFalse(Check.objects.exists())
|
self.assertFalse(Check.objects.exists())
|
||||||
|
|
||||||
# Subscription should have been canceled
|
# Subscription should have been canceled
|
||||||
self.assertTrue(mock_braintree.Subscription.cancel.called)
|
self.assertTrue(mock_cancel.called)
|
||||||
|
|
||||||
# Subscription should be gone
|
# Subscription should be gone
|
||||||
self.assertFalse(Subscription.objects.exists())
|
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):
|
def test_partner_removal_works(self):
|
||||||
self.client.login(username="bob@example.org", password="password")
|
self.client.login(username="bob@example.org", password="password")
|
||||||
self.set_sudo_flag()
|
r = self.client.post("/accounts/close/")
|
||||||
|
self.assertEqual(r.status_code, 302)
|
||||||
payload = {"confirmation": "bob@example.org"}
|
|
||||||
r = self.client.post("/accounts/close/", payload)
|
|
||||||
self.assertRedirects(r, "/")
|
|
||||||
|
|
||||||
# Alice should be still present
|
# Alice should be still present
|
||||||
self.alice.refresh_from_db()
|
self.alice.refresh_from_db()
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
|
self.assertEqual(self.profile.current_team, None)
|
||||||
|
|
||||||
# Bob should be gone
|
# Bob should be gone
|
||||||
bobs = User.objects.filter(username="bob")
|
bobs = User.objects.filter(username="bob")
|
||||||
self.assertFalse(bobs.exists())
|
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.core import mail
|
||||||
|
from django.test import TestCase
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from hc.accounts.models import Credential
|
from hc.accounts.models import Profile
|
||||||
from hc.api.models import Check, TokenBucket
|
from hc.api.models import Check
|
||||||
from hc.test import BaseTestCase
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
class LoginTestCase(BaseTestCase):
|
class LoginTestCase(TestCase):
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
self.checks_url = f"/projects/{self.project.code}/checks/"
|
|
||||||
|
|
||||||
def test_it_shows_form(self):
|
|
||||||
r = self.client.get("/accounts/login/")
|
|
||||||
self.assertContains(r, "Email Me a Link")
|
|
||||||
|
|
||||||
def test_it_redirects_authenticated_get(self):
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
|
|
||||||
r = self.client.get("/accounts/login/")
|
|
||||||
self.assertRedirects(r, self.checks_url)
|
|
||||||
|
|
||||||
def test_it_sends_link(self):
|
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)
|
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)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
subject = "Log in to %s" % settings.SITE_NAME
|
subject = "Log in to %s" % settings.SITE_NAME
|
||||||
self.assertEqual(mail.outbox[0].subject, subject)
|
self.assertEqual(mail.outbox[0].subject, subject)
|
||||||
|
|
||||||
def test_it_sends_link_with_next(self):
|
# And check should be associated with the new user
|
||||||
form = {"identity": "alice@example.org"}
|
check_again = Check.objects.get(code=check.code)
|
||||||
|
assert check_again.user
|
||||||
r = self.client.post("/accounts/login/?next=" + self.channels_url, form)
|
|
||||||
self.assertRedirects(r, "/accounts/login_link_sent/")
|
|
||||||
self.assertIn("auto-login", r.cookies)
|
|
||||||
|
|
||||||
# The check_token link should have a ?next= query parameter:
|
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
|
||||||
body = mail.outbox[0].body
|
|
||||||
self.assertTrue("/?next=" + self.channels_url in body)
|
|
||||||
|
|
||||||
@override_settings(SECRET_KEY="test-secret")
|
|
||||||
def test_it_rate_limits_emails(self):
|
|
||||||
# "d60d..." is sha1("alice@example.orgtest-secret")
|
|
||||||
obj = TokenBucket(value="em-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
|
|
||||||
obj.tokens = 0
|
|
||||||
obj.save()
|
|
||||||
|
|
||||||
form = {"identity": "alice@example.org"}
|
|
||||||
|
|
||||||
r = self.client.post("/accounts/login/", form)
|
|
||||||
self.assertContains(r, "Too many attempts")
|
|
||||||
|
|
||||||
# No email should have been sent
|
|
||||||
self.assertEqual(len(mail.outbox), 0)
|
|
||||||
|
|
||||||
def test_it_pops_bad_link_from_session(self):
|
def test_it_pops_bad_link_from_session(self):
|
||||||
self.client.session["bad_link"] = True
|
self.client.session["bad_link"] = True
|
||||||
self.client.get("/accounts/login/")
|
self.client.get("/accounts/login/")
|
||||||
assert "bad_link" not in self.client.session
|
assert "bad_link" not in self.client.session
|
||||||
|
|
||||||
def test_it_ignores_case(self):
|
def test_it_handles_missing_welcome_check(self):
|
||||||
form = {"identity": "ALICE@EXAMPLE.ORG"}
|
|
||||||
|
# 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)
|
r = self.client.post("/accounts/login/", form)
|
||||||
self.assertRedirects(r, "/accounts/login_link_sent/")
|
assert r.status_code == 302
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
# An user should have been created
|
||||||
self.assertIn("login", self.profile.token)
|
self.assertEqual(User.objects.count(), 1)
|
||||||
|
|
||||||
def test_it_handles_password(self):
|
# And email sent
|
||||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
subject = "Log in to %s" % settings.SITE_NAME
|
||||||
r = self.client.post("/accounts/login/", form)
|
self.assertEqual(mail.outbox[0].subject, subject)
|
||||||
self.assertRedirects(r, self.checks_url)
|
|
||||||
|
|
||||||
@override_settings(SECRET_KEY="test-secret")
|
|
||||||
def test_it_rate_limits_password_attempts(self):
|
|
||||||
# "d60d..." is sha1("alice@example.orgtest-secret")
|
|
||||||
obj = TokenBucket(value="pw-d60db3b2343e713a4de3e92d4eb417e4f05f06ab")
|
|
||||||
obj.tokens = 0
|
|
||||||
obj.save()
|
|
||||||
|
|
||||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
|
||||||
|
|
||||||
r = self.client.post("/accounts/login/", form)
|
|
||||||
self.assertContains(r, "Too many attempts")
|
|
||||||
|
|
||||||
def test_it_handles_password_login_with_redirect(self):
|
|
||||||
check = Check.objects.create(project=self.project)
|
|
||||||
|
|
||||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
|
||||||
|
|
||||||
samples = [self.channels_url, "/checks/%s/details/" % check.code]
|
|
||||||
|
|
||||||
for s in samples:
|
|
||||||
r = self.client.post("/accounts/login/?next=%s" % s, form)
|
|
||||||
self.assertRedirects(r, s)
|
|
||||||
|
|
||||||
def test_it_handles_bad_next_parameter(self):
|
|
||||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
|
||||||
|
|
||||||
samples = [
|
|
||||||
"/evil/",
|
|
||||||
f"https://example.org/projects/{self.project.code}/checks/",
|
|
||||||
]
|
|
||||||
|
|
||||||
for sample in samples:
|
|
||||||
r = self.client.post("/accounts/login/?next=" + sample, form)
|
|
||||||
self.assertRedirects(r, self.checks_url)
|
|
||||||
|
|
||||||
def test_it_handles_wrong_password(self):
|
|
||||||
form = {
|
|
||||||
"action": "login",
|
|
||||||
"email": "alice@example.org",
|
|
||||||
"password": "wrong password",
|
|
||||||
}
|
|
||||||
|
|
||||||
r = self.client.post("/accounts/login/", form)
|
|
||||||
self.assertContains(r, "Incorrect email or password")
|
|
||||||
|
|
||||||
@override_settings(REGISTRATION_OPEN=False)
|
@override_settings(REGISTRATION_OPEN=False)
|
||||||
def test_it_obeys_registration_open(self):
|
def test_it_obeys_registration_open(self):
|
||||||
r = self.client.get("/accounts/login/")
|
form = {"email": "dan@example.org"}
|
||||||
self.assertNotContains(r, "Create Your Account")
|
|
||||||
|
|
||||||
def test_it_redirects_to_webauthn_form(self):
|
|
||||||
Credential.objects.create(user=self.alice, name="Alices Key")
|
|
||||||
|
|
||||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
|
||||||
r = self.client.post("/accounts/login/", form)
|
r = self.client.post("/accounts/login/", form)
|
||||||
self.assertRedirects(
|
assert r.status_code == 200
|
||||||
r, "/accounts/login/two_factor/", fetch_redirect_response=False
|
self.assertContains(r, "Incorrect email")
|
||||||
)
|
|
||||||
|
|
||||||
# It should not log the user in yet
|
def test_it_ignores_ces(self):
|
||||||
self.assertNotIn("_auth_user_id", self.client.session)
|
alice = User(username="alice", email="alice@example.org")
|
||||||
|
alice.save()
|
||||||
|
|
||||||
# Instead, it should set 2fa_user_id in the session
|
form = {"email": "ALICE@EXAMPLE.ORG"}
|
||||||
user_id, email, valid_until = self.client.session["2fa_user"]
|
|
||||||
self.assertEqual(user_id, self.alice.id)
|
|
||||||
|
|
||||||
def test_it_redirects_to_totp_form(self):
|
|
||||||
self.profile.totp = "0" * 32
|
|
||||||
self.profile.save()
|
|
||||||
|
|
||||||
form = {"action": "login", "email": "alice@example.org", "password": "password"}
|
|
||||||
r = self.client.post("/accounts/login/", form)
|
r = self.client.post("/accounts/login/", form)
|
||||||
self.assertRedirects(
|
assert r.status_code == 302
|
||||||
r, "/accounts/login/two_factor/totp/", fetch_redirect_response=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# It should not log the user in yet
|
# There should be exactly one user:
|
||||||
self.assertNotIn("_auth_user_id", self.client.session)
|
self.assertEqual(User.objects.count(), 1)
|
||||||
|
|
||||||
# Instead, it should set 2fa_user_id in the session
|
profile = Profile.objects.for_user(alice)
|
||||||
user_id, email, valid_until = self.client.session["2fa_user"]
|
self.assertIn("login", profile.token)
|
||||||
self.assertEqual(user_id, self.alice.id)
|
|
||||||
|
@ -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 datetime import timedelta as td
|
||||||
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from hc.api.models import Check
|
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
class NotificationsTestCase(BaseTestCase):
|
class NotificationsTestCase(BaseTestCase):
|
||||||
url = "/accounts/profile/notifications/"
|
|
||||||
|
|
||||||
def _payload(self, **kwargs):
|
def test_it_saves_reports_allowed_true(self):
|
||||||
result = {"reports": "monthly", "nag_period": "0", "tz": "Europe/Riga"}
|
self.profile.reports_allowed = False
|
||||||
result.update(kwargs)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def test_it_saves_reports_monthly(self):
|
|
||||||
self.profile.reports = "off"
|
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
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.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertEqual(self.profile.reports, "monthly")
|
self.assertTrue(self.profile.reports_allowed)
|
||||||
self.assertEqual(self.profile.next_report_date.day, 1)
|
self.assertIsNotNone(self.profile.next_report_date)
|
||||||
|
|
||||||
def test_it_saves_reports_weekly(self):
|
def test_it_saves_reports_allowed_false(self):
|
||||||
self.profile.reports = "off"
|
self.profile.reports_allowed = True
|
||||||
self.profile.save()
|
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
|
||||||
|
|
||||||
r = self.client.post(self.url, self._payload(reports="weekly"))
|
|
||||||
self.assertEqual(r.status_code, 200)
|
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
|
||||||
self.assertEqual(self.profile.reports, "weekly")
|
|
||||||
self.assertEqual(self.profile.next_report_date.weekday(), 0)
|
|
||||||
|
|
||||||
def test_it_saves_reports_off(self):
|
|
||||||
self.profile.reports = "monthly"
|
|
||||||
self.profile.next_report_date = now()
|
self.profile.next_report_date = now()
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
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.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
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.assertIsNone(self.profile.next_report_date)
|
||||||
|
|
||||||
def test_it_sets_next_nag_date_when_setting_hourly_nag_period(self):
|
def test_it_saves_hourly_nag_period(self):
|
||||||
Check.objects.create(project=self.project, status="down")
|
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
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.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
||||||
self.assertIsNotNone(self.profile.next_nag_date)
|
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):
|
def test_it_does_not_save_nonstandard_nag_period(self):
|
||||||
self.profile.nag_period = td(seconds=3600)
|
self.profile.nag_period = td(seconds=3600)
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
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.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
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.test import BaseTestCase
|
||||||
from hc.accounts.models import Credential
|
from hc.accounts.models import Member
|
||||||
|
from hc.api.models import Check
|
||||||
|
|
||||||
|
|
||||||
class ProfileTestCase(BaseTestCase):
|
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")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
r = self.client.get("/accounts/profile/")
|
form = {"set_password": "1"}
|
||||||
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"}
|
|
||||||
r = self.client.post("/accounts/profile/", form)
|
r = self.client.post("/accounts/profile/", form)
|
||||||
self.assertContains(r, "Left project <strong>Alices Project</strong>")
|
assert r.status_code == 302
|
||||||
self.assertNotContains(r, "Member")
|
|
||||||
|
|
||||||
self.bobs_profile.refresh_from_db()
|
# profile.token should be set now
|
||||||
self.assertFalse(self.bob.memberships.exists())
|
self.profile.refresh_from_db()
|
||||||
|
token = self.profile.token
|
||||||
|
self.assertTrue(len(token) > 10)
|
||||||
|
|
||||||
def test_leaving_checks_membership(self):
|
# And an email should have been sent
|
||||||
self.client.login(username="charlie@example.org", password="password")
|
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)
|
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.profile.refresh_from_db()
|
||||||
self.client.login(username="bob@example.org", password="password")
|
api_key = self.profile.api_key
|
||||||
|
self.assertTrue(len(api_key) > 10)
|
||||||
r = self.client.get("/accounts/profile/")
|
|
||||||
self.assertContains(r, "Alices Project")
|
|
||||||
self.assertContains(r, "Member")
|
|
||||||
|
|
||||||
def test_it_shows_readonly_project_membership(self):
|
|
||||||
self.bobs_membership.role = "r"
|
|
||||||
self.bobs_membership.save()
|
|
||||||
|
|
||||||
self.client.login(username="bob@example.org", password="password")
|
|
||||||
|
|
||||||
r = self.client.get("/accounts/profile/")
|
|
||||||
self.assertContains(r, "Alices Project")
|
|
||||||
self.assertContains(r, "Read-only")
|
|
||||||
|
|
||||||
def test_it_handles_no_projects(self):
|
|
||||||
self.project.delete()
|
|
||||||
|
|
||||||
|
def test_it_revokes_api_key(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
r = self.client.get("/accounts/profile/")
|
form = {"revoke_api_key": "1"}
|
||||||
self.assertContains(r, "You do not have any projects. Create one!")
|
r = self.client.post("/accounts/profile/", form)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
@override_settings(RP_ID=None)
|
self.profile.refresh_from_db()
|
||||||
def test_it_hides_security_keys_bits_if_rp_id_not_set(self):
|
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")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
r = self.client.get("/accounts/profile/")
|
form = {"invite_team_member": "1", "email": "frank@example.org"}
|
||||||
self.assertContains(r, "Two-factor Authentication")
|
r = self.client.post("/accounts/profile/", form)
|
||||||
self.assertNotContains(r, "Security keys")
|
self.assertEqual(r.status_code, 200)
|
||||||
self.assertNotContains(r, "Add Security Key")
|
|
||||||
|
|
||||||
@override_settings(RP_ID="testserver")
|
member_emails = set()
|
||||||
def test_it_handles_no_credentials(self):
|
for member in self.profile.member_set.all():
|
||||||
self.client.login(username="alice@example.org", password="password")
|
member_emails.add(member.user.email)
|
||||||
|
|
||||||
r = self.client.get("/accounts/profile/")
|
self.assertEqual(len(member_emails), 2)
|
||||||
self.assertContains(r, "Two-factor Authentication")
|
self.assertTrue("frank@example.org" in member_emails)
|
||||||
self.assertContains(r, "Your account does not have any configured two-factor")
|
|
||||||
|
|
||||||
@override_settings(RP_ID="testserver")
|
# And an email should have been sent
|
||||||
def test_it_shows_security_key(self):
|
subj = ('You have been invited to join'
|
||||||
Credential.objects.create(user=self.alice, name="Alices Key")
|
' alice@example.org on %s' % settings.SITE_NAME)
|
||||||
|
self.assertEqual(mail.outbox[0].subject, subj)
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
def test_it_checks_team_size(self):
|
||||||
r = self.client.get("/accounts/profile/")
|
self.profile.team_limit = 0
|
||||||
self.assertContains(r, "Alices Key")
|
|
||||||
|
|
||||||
def test_it_handles_unusable_password(self):
|
|
||||||
self.alice.set_unusable_password()
|
|
||||||
self.alice.save()
|
|
||||||
|
|
||||||
# Authenticate using the ProfileBackend and a token:
|
|
||||||
token = self.profile.prepare_token("login")
|
|
||||||
self.client.login(username="alice", token=token)
|
|
||||||
|
|
||||||
r = self.client.get("/accounts/profile/")
|
|
||||||
self.assertContains(r, "Set Password")
|
|
||||||
self.assertNotContains(r, "Change Password")
|
|
||||||
|
|
||||||
def test_it_shows_totp(self):
|
|
||||||
self.profile.totp = "0" * 32
|
|
||||||
self.profile.totp_created = "2020-01-01T00:00:00+00:00"
|
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
r = self.client.get("/accounts/profile/")
|
form = {"invite_team_member": "1", "email": "frank@example.org"}
|
||||||
self.assertContains(r, "Enabled")
|
r = self.client.post("/accounts/profile/", form)
|
||||||
self.assertContains(r, "configured on Jan 1, 2020")
|
self.assertEqual(r.status_code, 403)
|
||||||
self.assertNotContains(r, "Set Up Authenticator App")
|
|
||||||
|
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 datetime import timedelta
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from hc.accounts.management.commands.pruneusers import Command
|
from hc.accounts.management.commands.pruneusers import Command
|
||||||
from hc.accounts.models import Project
|
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
@ -17,20 +15,26 @@ class PruneUsersTestCase(BaseTestCase):
|
|||||||
self.charlie.save()
|
self.charlie.save()
|
||||||
|
|
||||||
# Charlie has one demo check
|
# Charlie has one demo check
|
||||||
charlies_project = Project.objects.create(owner=self.charlie)
|
Check(user=self.charlie).save()
|
||||||
Check(project=charlies_project).save()
|
|
||||||
|
|
||||||
Command(stdout=Mock()).handle()
|
Command().handle()
|
||||||
|
|
||||||
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
|
self.assertEqual(User.objects.filter(username="charlie").count(), 0)
|
||||||
self.assertEqual(Check.objects.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):
|
def test_it_leaves_team_members_alone(self):
|
||||||
self.bob.date_joined = self.year_ago
|
self.bob.date_joined = self.year_ago
|
||||||
self.bob.last_login = self.year_ago
|
self.bob.last_login = self.year_ago
|
||||||
self.bob.save()
|
self.bob.save()
|
||||||
|
|
||||||
Command(stdout=Mock()).handle()
|
Command().handle()
|
||||||
|
|
||||||
# Bob belongs to a team so should not get removed
|
# Bob belongs to a team so should not get removed
|
||||||
self.assertEqual(User.objects.filter(username="bob").count(), 1)
|
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):
|
class TeamAccessMiddlewareTestCase(TestCase):
|
||||||
|
|
||||||
def test_it_handles_missing_profile(self):
|
def test_it_handles_missing_profile(self):
|
||||||
user = User(username="ned", email="ned@example.org")
|
user = User(username="ned", email="ned@example.org")
|
||||||
user.set_password("password")
|
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
|
from datetime import timedelta as td
|
||||||
import time
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@ -8,55 +6,40 @@ from hc.test import BaseTestCase
|
|||||||
|
|
||||||
|
|
||||||
class UnsubscribeReportsTestCase(BaseTestCase):
|
class UnsubscribeReportsTestCase(BaseTestCase):
|
||||||
def test_it_unsubscribes(self):
|
|
||||||
|
def test_token_works(self):
|
||||||
self.profile.next_report_date = now()
|
self.profile.next_report_date = now()
|
||||||
self.profile.nag_period = td(hours=1)
|
self.profile.nag_period = td(hours=1)
|
||||||
self.profile.next_nag_date = now()
|
self.profile.next_nag_date = now()
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
sig = signing.TimestampSigner(salt="reports").sign("alice")
|
token = signing.Signer().sign("foo")
|
||||||
url = "/accounts/unsubscribe_reports/%s/" % sig
|
url = "/accounts/unsubscribe_reports/alice/?token=%s" % token
|
||||||
|
r = self.client.get(url)
|
||||||
r = self.client.post(url)
|
self.assertContains(r, "You have been unsubscribed")
|
||||||
self.assertContains(r, "Unsubscribed")
|
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
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.assertIsNone(self.profile.next_report_date)
|
||||||
|
|
||||||
self.assertEqual(self.profile.nag_period.total_seconds(), 0)
|
self.assertEqual(self.profile.nag_period.total_seconds(), 0)
|
||||||
self.assertIsNone(self.profile.next_nag_date)
|
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):
|
def test_bad_signature_gets_rejected(self):
|
||||||
url = "/accounts/unsubscribe_reports/invalid/"
|
url = "/accounts/unsubscribe_reports/invalid/"
|
||||||
r = self.client.get(url)
|
r = self.client.get(url)
|
||||||
self.assertContains(r, "Incorrect Link")
|
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
|
from hc.accounts import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("login/", views.login, name="hc-login"),
|
url(r'^login/$', views.login, name="hc-login"),
|
||||||
path("login/two_factor/", views.login_webauthn, name="hc-login-webauthn"),
|
url(r'^logout/$', views.logout, name="hc-logout"),
|
||||||
path("login/two_factor/totp/", views.login_totp, name="hc-login-totp"),
|
url(r'^login_link_sent/$',
|
||||||
path("logout/", views.logout, name="hc-logout"),
|
views.login_link_sent, name="hc-login-link-sent"),
|
||||||
path("signup/", views.signup, name="hc-signup"),
|
|
||||||
path("login_link_sent/", views.login_link_sent, name="hc-login-link-sent"),
|
url(r'^link_sent/$',
|
||||||
path(
|
views.link_sent, name="hc-link-sent"),
|
||||||
"check_token/<slug:username>/<slug:token>/",
|
|
||||||
views.check_token,
|
url(r'^check_token/([\w-]+)/([\w-]+)/$',
|
||||||
name="hc-check-token",
|
views.check_token, name="hc-check-token"),
|
||||||
),
|
|
||||||
path("profile/", views.profile, name="hc-profile"),
|
url(r'^profile/$', views.profile, name="hc-profile"),
|
||||||
path("profile/appearance/", views.appearance, name="hc-appearance"),
|
url(r'^profile/notifications/$', views.notifications, name="hc-notifications"),
|
||||||
path("profile/notifications/", views.notifications, name="hc-notifications"),
|
url(r'^profile/badges/$', views.badges, name="hc-badges"),
|
||||||
path("close/", views.close, name="hc-close"),
|
url(r'^close/$', views.close, name="hc-close"),
|
||||||
path(
|
|
||||||
"unsubscribe_reports/<str:signed_username>/",
|
url(r'^unsubscribe_reports/([\w\:-]+)/$',
|
||||||
views.unsubscribe_reports,
|
views.unsubscribe_reports, name="hc-unsubscribe-reports"),
|
||||||
name="hc-unsubscribe-reports",
|
|
||||||
),
|
url(r'^set_password/([\w-]+)/$',
|
||||||
path("set_password/", views.set_password, name="hc-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"),
|
url(r'^change_email/done/$',
|
||||||
path("two_factor/webauthn/", views.add_webauthn, name="hc-add-webauthn"),
|
views.change_email_done, name="hc-change-email-done"),
|
||||||
path("two_factor/totp/", views.add_totp, name="hc-add-totp"),
|
|
||||||
path("two_factor/totp/remove/", views.remove_totp, name="hc-remove-totp"),
|
url(r'^change_email/([\w-]+)/$',
|
||||||
path(
|
views.change_email, name="hc-change-email"),
|
||||||
"two_factor/<uuid:code>/remove/",
|
|
||||||
views.remove_credential,
|
url(r'^switch_team/([\w-]+)/$',
|
||||||
name="hc-remove-credential",
|
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.contrib import admin
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db import connection
|
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 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
|
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)
|
@admin.register(Check)
|
||||||
class ChecksAdmin(admin.ModelAdmin):
|
class ChecksAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
css = {"all": ("css/admin/checks.css",)}
|
css = {
|
||||||
|
'all': ('css/admin/checks.css',)
|
||||||
search_fields = ["name", "code", "project__owner__email"]
|
}
|
||||||
raw_id_fields = ("project",)
|
|
||||||
list_display = (
|
|
||||||
"id",
|
|
||||||
"name_tags",
|
|
||||||
"project_",
|
|
||||||
"created",
|
|
||||||
"n_pings",
|
|
||||||
"timeout_schedule",
|
|
||||||
"status",
|
|
||||||
"last_start",
|
|
||||||
"last_ping",
|
|
||||||
)
|
|
||||||
list_filter = ("status", "kind", "last_ping", "last_start")
|
|
||||||
|
|
||||||
|
search_fields = ["name", "user__email", "code"]
|
||||||
|
list_display = ("id", "name_tags", "created", "code", "timeout_schedule",
|
||||||
|
"status", "email", "last_ping", "n_pings")
|
||||||
|
list_select_related = ("user", )
|
||||||
|
list_filter = ("status", OwnershipListFilter, "kind", "last_ping")
|
||||||
actions = ["send_alert"]
|
actions = ["send_alert"]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def email(self, obj):
|
||||||
qs = super().get_queryset(request)
|
return obj.user.email if obj.user else None
|
||||||
qs = qs.annotate(email=F("project__owner__email"))
|
|
||||||
qs = qs.annotate(project_name=F("project__name"))
|
|
||||||
return qs
|
|
||||||
|
|
||||||
@mark_safe
|
|
||||||
def project_(self, obj):
|
|
||||||
url = reverse("hc-checks", args=[obj.project.code])
|
|
||||||
name = escape(obj.project_name or "Default")
|
|
||||||
email = escape(obj.email)
|
|
||||||
return f'{email} › <a href="{url}"">{name}</a>'
|
|
||||||
|
|
||||||
@mark_safe
|
|
||||||
def name_tags(self, obj):
|
def name_tags(self, obj):
|
||||||
url = reverse("hc-details", args=[obj.code])
|
if not obj.tags:
|
||||||
name = escape(obj.name or "unnamed")
|
return obj.name
|
||||||
|
|
||||||
s = f'<a href="{url}"">{name}</a>'
|
return "%s [%s]" % (obj.name, obj.tags)
|
||||||
for tag in obj.tags_list():
|
|
||||||
s += " <span>%s</span>" % escape(tag)
|
|
||||||
|
|
||||||
return s
|
|
||||||
|
|
||||||
@admin.display(description="Schedule")
|
|
||||||
def timeout_schedule(self, obj):
|
def timeout_schedule(self, obj):
|
||||||
if obj.kind == "simple":
|
if obj.kind == "simple":
|
||||||
return format_duration(obj.timeout)
|
return format_duration(obj.timeout)
|
||||||
@ -64,21 +53,27 @@ class ChecksAdmin(admin.ModelAdmin):
|
|||||||
else:
|
else:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
@admin.action(description="Send Alert")
|
timeout_schedule.short_description = "Schedule"
|
||||||
|
|
||||||
def send_alert(self, request, qs):
|
def send_alert(self, request, qs):
|
||||||
for check in qs:
|
for check in qs:
|
||||||
for channel in check.channel_set.all():
|
check.send_alert()
|
||||||
channel.notify(check)
|
|
||||||
|
|
||||||
self.message_user(request, "%d alert(s) sent" % qs.count())
|
self.message_user(request, "%d alert(s) sent" % qs.count())
|
||||||
|
|
||||||
|
send_alert.short_description = "Send Alert"
|
||||||
|
|
||||||
|
|
||||||
class SchemeListFilter(admin.SimpleListFilter):
|
class SchemeListFilter(admin.SimpleListFilter):
|
||||||
title = "Scheme"
|
title = "Scheme"
|
||||||
parameter_name = "scheme"
|
parameter_name = 'scheme'
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
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):
|
def queryset(self, request, queryset):
|
||||||
if self.value():
|
if self.value():
|
||||||
@ -88,7 +83,7 @@ class SchemeListFilter(admin.SimpleListFilter):
|
|||||||
|
|
||||||
class MethodListFilter(admin.SimpleListFilter):
|
class MethodListFilter(admin.SimpleListFilter):
|
||||||
title = "Method"
|
title = "Method"
|
||||||
parameter_name = "method"
|
parameter_name = 'method'
|
||||||
methods = ["HEAD", "GET", "POST", "PUT", "DELETE"]
|
methods = ["HEAD", "GET", "POST", "PUT", "DELETE"]
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
def lookups(self, request, model_admin):
|
||||||
@ -100,20 +95,6 @@ class MethodListFilter(admin.SimpleListFilter):
|
|||||||
return queryset
|
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/
|
# Adapted from: https://djangosnippets.org/snippets/2593/
|
||||||
class LargeTablePaginator(Paginator):
|
class LargeTablePaginator(Paginator):
|
||||||
""" Overrides the count method to get an estimate instead of actual count
|
""" Overrides the count method to get an estimate instead of actual count
|
||||||
@ -123,10 +104,8 @@ class LargeTablePaginator(Paginator):
|
|||||||
def _get_estimate(self):
|
def _get_estimate(self):
|
||||||
try:
|
try:
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
cursor.execute(
|
cursor.execute("SELECT reltuples FROM pg_class WHERE relname = %s",
|
||||||
"SELECT reltuples FROM pg_class WHERE relname = %s",
|
[self.object_list.query.model._meta.db_table])
|
||||||
[self.object_list.query.model._meta.db_table],
|
|
||||||
)
|
|
||||||
return int(cursor.fetchone()[0])
|
return int(cursor.fetchone()[0])
|
||||||
except:
|
except:
|
||||||
return 0
|
return 0
|
||||||
@ -151,84 +130,70 @@ class LargeTablePaginator(Paginator):
|
|||||||
# (i.e. is of type list).
|
# (i.e. is of type list).
|
||||||
self._count = len(self.object_list)
|
self._count = len(self.object_list)
|
||||||
return self._count
|
return self._count
|
||||||
|
|
||||||
count = property(_get_count)
|
count = property(_get_count)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Ping)
|
@admin.register(Ping)
|
||||||
class PingsAdmin(admin.ModelAdmin):
|
class PingsAdmin(admin.ModelAdmin):
|
||||||
search_fields = ("owner__name", "owner__code")
|
search_fields = ("owner__name", "owner__code", "owner__user__email")
|
||||||
readonly_fields = ("owner",)
|
list_select_related = ("owner", "owner__user")
|
||||||
list_select_related = ("owner",)
|
list_display = ("id", "created", "check_name", "email", "scheme", "method",
|
||||||
list_display = ("id", "created", "owner", "scheme", "method", "ua")
|
"ua")
|
||||||
list_filter = ("created", SchemeListFilter, MethodListFilter, KindListFilter)
|
list_filter = ("created", SchemeListFilter, MethodListFilter)
|
||||||
|
|
||||||
paginator = LargeTablePaginator
|
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)
|
@admin.register(Channel)
|
||||||
class ChannelsAdmin(admin.ModelAdmin):
|
class ChannelsAdmin(admin.ModelAdmin):
|
||||||
class Media:
|
class Media:
|
||||||
css = {"all": ("css/admin/channels.css",)}
|
css = {
|
||||||
|
'all': ('css/admin/channels.css',)
|
||||||
|
}
|
||||||
|
|
||||||
search_fields = ["value", "project__owner__email"]
|
search_fields = ["value", "user__email"]
|
||||||
list_display = ("id", "transport", "name", "project_", "value", "ok")
|
list_select_related = ("user", )
|
||||||
list_filter = ("kind",)
|
list_display = ("id", "code", "email", "formatted_kind", "value",
|
||||||
raw_id_fields = ("project", "checks")
|
"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
|
@mark_safe
|
||||||
def project_(self, obj):
|
def formatted_kind(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 = ""
|
|
||||||
if obj.kind == "email" and not obj.email_verified:
|
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)
|
formatted_kind.short_description = "Kind"
|
||||||
def ok(self, obj):
|
|
||||||
return False if obj.last_error else True
|
def num_notifications(self, obj):
|
||||||
|
return Notification.objects.filter(channel=obj).count()
|
||||||
|
|
||||||
|
num_notifications.short_description = "# Notifications"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Notification)
|
@admin.register(Notification)
|
||||||
class NotificationsAdmin(admin.ModelAdmin):
|
class NotificationsAdmin(admin.ModelAdmin):
|
||||||
search_fields = ["owner__name", "owner__code", "channel__value", "error"]
|
search_fields = ["owner__name", "owner__code", "channel__value"]
|
||||||
readonly_fields = ("owner",)
|
|
||||||
list_select_related = ("owner", "channel")
|
list_select_related = ("owner", "channel")
|
||||||
list_display = (
|
list_display = ("id", "created", "check_status", "check_name",
|
||||||
"id",
|
"channel_kind", "channel_value")
|
||||||
"created",
|
|
||||||
"check_status",
|
|
||||||
"owner",
|
|
||||||
"channel_kind",
|
|
||||||
"channel_value",
|
|
||||||
"error",
|
|
||||||
)
|
|
||||||
list_filter = ("created", "check_status", "channel__kind")
|
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):
|
def channel_kind(self, obj):
|
||||||
return obj.channel.kind
|
return obj.channel.kind
|
||||||
|
|
||||||
def channel_value(self, obj):
|
def channel_value(self, obj):
|
||||||
return obj.channel.value
|
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 json
|
||||||
|
import re
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.contrib.auth.models import User
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import (HttpResponseBadRequest, HttpResponseForbidden,
|
||||||
from hc.accounts.models import Project
|
JsonResponse)
|
||||||
from hc.lib.jsonschema import ValidationError, validate
|
from hc.lib.jsonschema import ValidationError, validate
|
||||||
|
|
||||||
|
RE_UUID = re.compile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
|
||||||
def error(msg, status=400):
|
|
||||||
return JsonResponse({"error": msg}, status=status)
|
|
||||||
|
|
||||||
|
|
||||||
def authorize(f):
|
def uuid_or_400(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapper(request, *args, **kwds):
|
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:
|
if "HTTP_X_API_KEY" in request.META:
|
||||||
api_key = request.META["HTTP_X_API_KEY"]
|
api_key = request.META["HTTP_X_API_KEY"]
|
||||||
else:
|
else:
|
||||||
api_key = str(request.json.get("api_key", ""))
|
api_key = request.json.get("api_key", "")
|
||||||
|
|
||||||
if len(api_key) != 32:
|
if api_key == "":
|
||||||
return error("missing api key", 401)
|
return make_error("wrong api_key")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request.project = Project.objects.get(api_key=api_key)
|
request.user = User.objects.get(profile__api_key=api_key)
|
||||||
except Project.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return error("wrong api key", 401)
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
request.readonly = False
|
|
||||||
return f(request, *args, **kwds)
|
return f(request, *args, **kwds)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def authorize_read(f):
|
def validate_json(schema):
|
||||||
@wraps(f)
|
""" Validate request.json contents against `schema`.
|
||||||
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", ""))
|
|
||||||
|
|
||||||
if len(api_key) != 32:
|
Supports a tiny subset of JSON schema spec.
|
||||||
return error("missing api key", 401)
|
|
||||||
|
|
||||||
write_key_match = Q(api_key=api_key)
|
|
||||||
read_key_match = Q(api_key_readonly=api_key)
|
|
||||||
try:
|
|
||||||
request.project = Project.objects.get(write_key_match | read_key_match)
|
|
||||||
except Project.DoesNotExist:
|
|
||||||
return error("wrong api key", 401)
|
|
||||||
|
|
||||||
request.readonly = api_key == request.project.api_key_readonly
|
|
||||||
return f(request, *args, **kwds)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def validate_json(schema=None):
|
|
||||||
""" Parse request json and validate it against `schema`.
|
|
||||||
|
|
||||||
Put the parsed result in `request.json`.
|
|
||||||
If schema is None then only parse and don't validate.
|
|
||||||
Supports a limited subset of JSON schema spec.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(f):
|
def decorator(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapper(request, *args, **kwds):
|
def wrapper(request, *args, **kwds):
|
||||||
if request.body:
|
try:
|
||||||
try:
|
validate(request.json, schema)
|
||||||
request.json = json.loads(request.body.decode())
|
except ValidationError as e:
|
||||||
except ValueError:
|
return make_error("json validation error: %s" % e)
|
||||||
return error("could not parse request body")
|
|
||||||
else:
|
|
||||||
request.json = {}
|
|
||||||
|
|
||||||
if schema:
|
|
||||||
try:
|
|
||||||
validate(request.json, schema)
|
|
||||||
except ValidationError as e:
|
|
||||||
return error("json validation error: %s" % e)
|
|
||||||
|
|
||||||
return f(request, *args, **kwds)
|
return f(request, *args, **kwds)
|
||||||
|
|
||||||
return wrapper
|
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
|
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