mirror of
https://github.com/caronc/apprise-api.git
synced 2025-08-18 18:38:10 +02:00
Refactored code base; modernized with pyproject.toml (#255)
This commit is contained in:
24
.coveragerc
24
.coveragerc
@@ -1,24 +0,0 @@
|
|||||||
[run]
|
|
||||||
data_file = .coverage-reports/.coverage
|
|
||||||
parallel = False
|
|
||||||
concurrency = multiprocessing
|
|
||||||
include = apprise_api
|
|
||||||
omit =
|
|
||||||
*apps.py,
|
|
||||||
*/migrations/*,
|
|
||||||
*/core/settings/*,
|
|
||||||
*/*/tests/*,
|
|
||||||
lib/*,
|
|
||||||
lib64/*,
|
|
||||||
*urls.py,
|
|
||||||
*/core/wsgi.py,
|
|
||||||
gunicorn.conf.py,
|
|
||||||
*/manage.py
|
|
||||||
|
|
||||||
disable_warnings = no-data-collected
|
|
||||||
|
|
||||||
[report]
|
|
||||||
show_missing = True
|
|
||||||
skip_covered = True
|
|
||||||
skip_empty = True
|
|
||||||
fail_under = 75.0
|
|
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,9 +1,9 @@
|
|||||||
## Description:
|
## Description:
|
||||||
**Related issue (if applicable):** refs #<!--apprise issue number goes here-->
|
**Related issue (if applicable):** refs #<!--apprise-api issue number goes here-->
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
<!-- The following must be completed or your PR can't be merged -->
|
<!-- The following must be completed or your PR can't be merged -->
|
||||||
* [ ] The code change is tested and works locally.
|
* [ ] The code change is tested and works locally.
|
||||||
* [ ] There is no commented out code in this PR.
|
* [ ] There is no commented out code in this PR.
|
||||||
* [ ] No lint errors (use `flake8`)
|
* [ ] No lint errors (use `ruff`)
|
||||||
* [ ] Tests added
|
* [ ] Tests added
|
||||||
|
75
.github/workflows/tests.yml
vendored
75
.github/workflows/tests.yml
vendored
@@ -1,71 +1,55 @@
|
|||||||
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
# This code is licensed under the MIT License.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files(the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions :
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|
||||||
# On which repository actions to trigger the build.
|
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
branches: [ master ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master ]
|
branches: [ master ]
|
||||||
|
|
||||||
# Allow job to be triggered manually.
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Cancel in-progress jobs when pushing to the same branch.
|
|
||||||
concurrency:
|
|
||||||
cancel-in-progress: true
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
|
|
||||||
# Run all jobs to completion (false), or cancel
|
|
||||||
# all jobs once the first one fails (true).
|
|
||||||
fail-fast: true
|
|
||||||
|
|
||||||
# Define a minimal test matrix, it will be
|
|
||||||
# expanded using subsequent `include` items.
|
|
||||||
matrix:
|
|
||||||
os: ["ubuntu-latest"]
|
|
||||||
python-version: ["3.12"]
|
|
||||||
bare: [false]
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
env:
|
|
||||||
OS: ${{ matrix.os }}
|
|
||||||
PYTHON: ${{ matrix.python-version }}
|
|
||||||
BARE: ${{ matrix.bare }}
|
|
||||||
|
|
||||||
name: Python ${{ matrix.python-version }} on ${{ matrix.os }} ${{ matrix.bare && '(bare)' || '' }}
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Acquire sources
|
- name: Acquire sources
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install prerequisites (Linux)
|
- name: Install prerequisites
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
|
|
||||||
- name: Install project dependencies (Baseline)
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install -r requirements.txt -r dev-requirements.txt
|
pip install -r requirements.txt -r dev-requirements.txt
|
||||||
|
|
||||||
# For saving resources, code style checking is
|
- name: Lint with Ruff
|
||||||
# only invoked within the `bare` environment.
|
|
||||||
- name: Check code style
|
|
||||||
if: matrix.bare == true
|
|
||||||
run: |
|
run: |
|
||||||
flake8 apprise_api --count --show-source --statistics
|
ruff check apprise_api
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests with coverage
|
||||||
run: |
|
run: |
|
||||||
coverage run -m pytest apprise_api
|
coverage run -m pytest apprise_api
|
||||||
|
|
||||||
@@ -74,9 +58,10 @@ jobs:
|
|||||||
coverage xml
|
coverage xml
|
||||||
coverage report
|
coverage report
|
||||||
|
|
||||||
- name: Upload coverage data
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
fail_ci_if_error: false
|
fail_ci_if_error: false
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
.local/
|
||||||
lib64
|
lib64
|
||||||
lib
|
lib
|
||||||
var/
|
var/
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
# BSD 2-Clause License
|
# BSD 2-Clause License
|
||||||
#
|
#
|
||||||
# Apprise-API - Push Notification Library.
|
# Apprise-API - Push Notification Library.
|
||||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
|
||||||
#
|
#
|
||||||
# Redistribution and use in source and binary forms, with or without
|
# Redistribution and use in source and binary forms, with or without
|
||||||
# modification, are permitted provided that the following conditions are met:
|
# modification, are permitted provided that the following conditions are met:
|
||||||
|
68
README.md
68
README.md
@@ -616,33 +616,67 @@ spec:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Development Environment
|
## Development Environment
|
||||||
The following should get you a working development environment to test with:
|
The following should get you a working development environment (min requirements are Python v3.12) to test with.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a virtual environment in the same directory you
|
# Create and activate a Python 3.12 virtual environment:
|
||||||
# cloned this repository to:
|
python3.12 -m venv .venv
|
||||||
python -m venv .
|
. .venv/bin/activate
|
||||||
|
|
||||||
# Activate it now:
|
# Install core dependencies:
|
||||||
. ./bin/activate
|
|
||||||
|
|
||||||
# install dependencies
|
|
||||||
pip install -r dev-requirements.txt -r requirements.txt
|
|
||||||
|
|
||||||
# Run a dev server (debug mode) accessible from your browser at:
|
|
||||||
# -> http://localhost:8000/
|
|
||||||
./manage.py runserver
|
|
||||||
|
|
||||||
|
pip install -e '.[dev]'
|
||||||
```
|
```
|
||||||
|
|
||||||
Some other useful development notes:
|
### Running the Dev Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check for any lint errors
|
# Start the development server in debug mode:
|
||||||
flake8 apprise_api
|
./manage.py runserver
|
||||||
|
# Then visit: http://localhost:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Docker:
|
||||||
|
```bash
|
||||||
|
# It's this simple:
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quality Assurance and Testing (via Tox)
|
||||||
|
|
||||||
|
The project uses `tox` to manage linting, testing, and formatting in a reproducible way.
|
||||||
|
|
||||||
|
```bash
|
||||||
# Run unit tests
|
# Run unit tests
|
||||||
|
tox -e test
|
||||||
|
|
||||||
|
# Test structure; calls ruff under the hood
|
||||||
|
tox -e lint
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: You can combine environments, e.g.:
|
||||||
|
```bash
|
||||||
|
tox -e test,lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Automatically format your code if possible to pass linting after changes:
|
||||||
|
```bash
|
||||||
|
tox -e format
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Tools (optional)
|
||||||
|
The following also works assuming you have provided all development dependencies (`pip install .[dev]`)
|
||||||
|
```bash
|
||||||
|
# Run unit tests manually (if needed)
|
||||||
pytest apprise_api
|
pytest apprise_api
|
||||||
|
|
||||||
|
# Lint code with Ruff
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
# Format code with Ruff
|
||||||
|
ruff format .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Apprise Integration
|
## Apprise Integration
|
||||||
@@ -790,4 +824,6 @@ The colon `:` prefix is the switch that starts the re-mapping rule engine. You
|
|||||||
|
|
||||||
|
|
||||||
## Metrics Collection & Analysis
|
## Metrics Collection & Analysis
|
||||||
|
|
||||||
Basic Prometheus support added through `/metrics` reference point.
|
Basic Prometheus support added through `/metrics` reference point.
|
||||||
|
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -26,4 +25,4 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
|
|
||||||
class ApiConfig(AppConfig):
|
class ApiConfig(AppConfig):
|
||||||
name = 'api'
|
name = "api"
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,49 +21,50 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from .utils import gen_unique_config_id
|
|
||||||
from .utils import ConfigCache
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
import apprise
|
import apprise
|
||||||
|
|
||||||
|
from .utils import ConfigCache, gen_unique_config_id
|
||||||
|
|
||||||
|
|
||||||
def stateful_mode(request):
|
def stateful_mode(request):
|
||||||
"""
|
"""
|
||||||
Returns our loaded Stateful Mode
|
Returns our loaded Stateful Mode
|
||||||
"""
|
"""
|
||||||
return {'STATEFUL_MODE': ConfigCache.mode}
|
return {"STATEFUL_MODE": ConfigCache.mode}
|
||||||
|
|
||||||
|
|
||||||
def config_lock(request):
|
def config_lock(request):
|
||||||
"""
|
"""
|
||||||
Returns the state of our global configuration lock
|
Returns the state of our global configuration lock
|
||||||
"""
|
"""
|
||||||
return {'CONFIG_LOCK': settings.APPRISE_CONFIG_LOCK}
|
return {"CONFIG_LOCK": settings.APPRISE_CONFIG_LOCK}
|
||||||
|
|
||||||
|
|
||||||
def admin_enabled(request):
|
def admin_enabled(request):
|
||||||
"""
|
"""
|
||||||
Returns whether we allow the config list to be displayed
|
Returns whether we allow the config list to be displayed
|
||||||
"""
|
"""
|
||||||
return {'APPRISE_ADMIN': settings.APPRISE_ADMIN}
|
return {"APPRISE_ADMIN": settings.APPRISE_ADMIN}
|
||||||
|
|
||||||
|
|
||||||
def apprise_version(request):
|
def apprise_version(request):
|
||||||
"""
|
"""
|
||||||
Returns the current version of apprise loaded under the hood
|
Returns the current version of apprise loaded under the hood
|
||||||
"""
|
"""
|
||||||
return {'APPRISE_VERSION': apprise.__version__}
|
return {"APPRISE_VERSION": apprise.__version__}
|
||||||
|
|
||||||
|
|
||||||
def default_config_id(request):
|
def default_config_id(request):
|
||||||
"""
|
"""
|
||||||
Returns a unique config identifier
|
Returns a unique config identifier
|
||||||
"""
|
"""
|
||||||
return {'DEFAULT_CONFIG_ID': request.default_config_id}
|
return {"DEFAULT_CONFIG_ID": request.default_config_id}
|
||||||
|
|
||||||
|
|
||||||
def unique_config_id(request):
|
def unique_config_id(request):
|
||||||
"""
|
"""
|
||||||
Returns a unique config identifier
|
Returns a unique config identifier
|
||||||
"""
|
"""
|
||||||
return {'UNIQUE_CONFIG_ID': gen_unique_config_id()}
|
return {"UNIQUE_CONFIG_ID": gen_unique_config_id()}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -23,39 +22,39 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
|
||||||
import apprise
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import apprise
|
||||||
|
|
||||||
# Auto-Detect Keyword
|
# Auto-Detect Keyword
|
||||||
AUTO_DETECT_CONFIG_KEYWORD = 'auto'
|
AUTO_DETECT_CONFIG_KEYWORD = "auto"
|
||||||
|
|
||||||
# Define our potential configuration types
|
# Define our potential configuration types
|
||||||
CONFIG_FORMATS = (
|
CONFIG_FORMATS = (
|
||||||
(AUTO_DETECT_CONFIG_KEYWORD, _('Auto-Detect')),
|
(AUTO_DETECT_CONFIG_KEYWORD, _("Auto-Detect")),
|
||||||
(apprise.ConfigFormat.TEXT, _('TEXT')),
|
(apprise.ConfigFormat.TEXT.value, _("TEXT")),
|
||||||
(apprise.ConfigFormat.YAML, _('YAML')),
|
(apprise.ConfigFormat.YAML.value, _("YAML")),
|
||||||
)
|
)
|
||||||
|
|
||||||
NOTIFICATION_TYPES = (
|
NOTIFICATION_TYPES = (
|
||||||
(apprise.NotifyType.INFO, _('Info')),
|
(apprise.NotifyType.INFO.value, _("Info")),
|
||||||
(apprise.NotifyType.SUCCESS, _('Success')),
|
(apprise.NotifyType.SUCCESS.value, _("Success")),
|
||||||
(apprise.NotifyType.WARNING, _('Warning')),
|
(apprise.NotifyType.WARNING.value, _("Warning")),
|
||||||
(apprise.NotifyType.FAILURE, _('Failure')),
|
(apprise.NotifyType.FAILURE.value, _("Failure")),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Define our potential input text categories
|
# Define our potential input text categories
|
||||||
INPUT_FORMATS = (
|
INPUT_FORMATS = (
|
||||||
(apprise.NotifyFormat.TEXT, _('TEXT')),
|
(apprise.NotifyFormat.TEXT.value, _("TEXT")),
|
||||||
(apprise.NotifyFormat.MARKDOWN, _('MARKDOWN')),
|
(apprise.NotifyFormat.MARKDOWN.value, _("MARKDOWN")),
|
||||||
(apprise.NotifyFormat.HTML, _('HTML')),
|
(apprise.NotifyFormat.HTML.value, _("HTML")),
|
||||||
# As-is - do not interpret it
|
# As-is - do not interpret it
|
||||||
(None, _('IGNORE')),
|
(None, _("IGNORE")),
|
||||||
)
|
)
|
||||||
|
|
||||||
URLS_MAX_LEN = 1024
|
URLS_MAX_LEN = 1024
|
||||||
URLS_PLACEHOLDER = 'mailto://user:pass@domain.com, ' \
|
URLS_PLACEHOLDER = "mailto://user:pass@domain.com, slack://tokena/tokenb/tokenc, ..."
|
||||||
'slack://tokena/tokenb/tokenc, ...'
|
|
||||||
|
|
||||||
|
|
||||||
class AddByUrlForm(forms.Form):
|
class AddByUrlForm(forms.Form):
|
||||||
@@ -66,9 +65,10 @@ class AddByUrlForm(forms.Form):
|
|||||||
|
|
||||||
This content can just be directly fed straight into Apprise
|
This content can just be directly fed straight into Apprise
|
||||||
"""
|
"""
|
||||||
|
|
||||||
urls = forms.CharField(
|
urls = forms.CharField(
|
||||||
label=_('URLs'),
|
label=_("URLs"),
|
||||||
widget=forms.TextInput(attrs={'placeholder': URLS_PLACEHOLDER}),
|
widget=forms.TextInput(attrs={"placeholder": URLS_PLACEHOLDER}),
|
||||||
max_length=URLS_MAX_LEN,
|
max_length=URLS_MAX_LEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,14 +80,14 @@ class AddByConfigForm(forms.Form):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
format = forms.ChoiceField(
|
format = forms.ChoiceField(
|
||||||
label=_('Format'),
|
label=_("Format"),
|
||||||
choices=CONFIG_FORMATS,
|
choices=CONFIG_FORMATS,
|
||||||
initial=CONFIG_FORMATS[0][0],
|
initial=CONFIG_FORMATS[0][0],
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
config = forms.CharField(
|
config = forms.CharField(
|
||||||
label=_('Configuration'),
|
label=_("Configuration"),
|
||||||
widget=forms.Textarea(),
|
widget=forms.Textarea(),
|
||||||
max_length=4096,
|
max_length=4096,
|
||||||
required=False,
|
required=False,
|
||||||
@@ -97,7 +97,7 @@ class AddByConfigForm(forms.Form):
|
|||||||
"""
|
"""
|
||||||
We just ensure there is a format always set and it defaults to auto
|
We just ensure there is a format always set and it defaults to auto
|
||||||
"""
|
"""
|
||||||
data = self.cleaned_data['format']
|
data = self.cleaned_data["format"]
|
||||||
if not data:
|
if not data:
|
||||||
# Set to auto
|
# Set to auto
|
||||||
data = CONFIG_FORMATS[0][0]
|
data = CONFIG_FORMATS[0][0]
|
||||||
@@ -111,44 +111,42 @@ class NotifyForm(forms.Form):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
format = forms.ChoiceField(
|
format = forms.ChoiceField(
|
||||||
label=_('Process As'),
|
label=_("Process As"),
|
||||||
initial=INPUT_FORMATS[0][0],
|
initial=INPUT_FORMATS[0][0],
|
||||||
choices=INPUT_FORMATS,
|
choices=INPUT_FORMATS,
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
label=_('Type'),
|
label=_("Type"),
|
||||||
choices=NOTIFICATION_TYPES,
|
choices=NOTIFICATION_TYPES,
|
||||||
initial=NOTIFICATION_TYPES[0][0],
|
initial=NOTIFICATION_TYPES[0][0],
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
title = forms.CharField(
|
title = forms.CharField(
|
||||||
label=_('Title'),
|
label=_("Title"),
|
||||||
widget=forms.TextInput(attrs={'placeholder': _('Optional Title')}),
|
widget=forms.TextInput(attrs={"placeholder": _("Optional Title")}),
|
||||||
max_length=apprise.NotifyBase.title_maxlen,
|
max_length=apprise.NotifyBase.title_maxlen,
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
body = forms.CharField(
|
body = forms.CharField(
|
||||||
label=_('Body'),
|
label=_("Body"),
|
||||||
widget=forms.Textarea(
|
widget=forms.Textarea(attrs={"placeholder": _("Define your message body here...")}),
|
||||||
attrs={'placeholder': _('Define your message body here...')}),
|
|
||||||
max_length=apprise.NotifyBase.body_maxlen,
|
max_length=apprise.NotifyBase.body_maxlen,
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Attachment Support
|
# Attachment Support
|
||||||
attachment = forms.FileField(
|
attachment = forms.FileField(
|
||||||
label=_('Attachment'),
|
label=_("Attachment"),
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
tag = forms.CharField(
|
tag = forms.CharField(
|
||||||
label=_('Tags'),
|
label=_("Tags"),
|
||||||
widget=forms.TextInput(
|
widget=forms.TextInput(attrs={"placeholder": _("Optional_Tag1, Optional_Tag2, ...")}),
|
||||||
attrs={'placeholder': _('Optional_Tag1, Optional_Tag2, ...')}),
|
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,7 +154,7 @@ class NotifyForm(forms.Form):
|
|||||||
# always take priority over this however adding `tags` gives the user more
|
# always take priority over this however adding `tags` gives the user more
|
||||||
# flexibilty to use either/or keyword
|
# flexibilty to use either/or keyword
|
||||||
tags = forms.CharField(
|
tags = forms.CharField(
|
||||||
label=_('Tags'),
|
label=_("Tags"),
|
||||||
widget=forms.HiddenInput(),
|
widget=forms.HiddenInput(),
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
@@ -165,20 +163,20 @@ class NotifyForm(forms.Form):
|
|||||||
"""
|
"""
|
||||||
We just ensure there is a type always set
|
We just ensure there is a type always set
|
||||||
"""
|
"""
|
||||||
data = self.cleaned_data['type']
|
data = self.cleaned_data["type"]
|
||||||
if not data:
|
if not data:
|
||||||
# Always set a type
|
# Always set a type
|
||||||
data = apprise.NotifyType.INFO
|
data = apprise.NotifyType.INFO.value
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def clean_format(self):
|
def clean_format(self):
|
||||||
"""
|
"""
|
||||||
We just ensure there is a format always set
|
We just ensure there is a format always set
|
||||||
"""
|
"""
|
||||||
data = self.cleaned_data['format']
|
data = self.cleaned_data["format"]
|
||||||
if not data:
|
if not data:
|
||||||
# Always set a type
|
# Always set a type
|
||||||
data = apprise.NotifyFormat.TEXT
|
data = apprise.NotifyFormat.TEXT.value
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -187,9 +185,10 @@ class NotifyByUrlForm(NotifyForm):
|
|||||||
Same as the NotifyForm but additionally processes a string of URLs to
|
Same as the NotifyForm but additionally processes a string of URLs to
|
||||||
notify directly.
|
notify directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
urls = forms.CharField(
|
urls = forms.CharField(
|
||||||
label=_('URLs'),
|
label=_("URLs"),
|
||||||
widget=forms.TextInput(attrs={'placeholder': URLS_PLACEHOLDER}),
|
widget=forms.TextInput(attrs={"placeholder": URLS_PLACEHOLDER}),
|
||||||
max_length=URLS_MAX_LEN,
|
max_length=URLS_MAX_LEN,
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
@@ -23,8 +22,9 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
import apprise
|
import apprise
|
||||||
|
|
||||||
|
|
||||||
@@ -32,14 +32,18 @@ class Command(BaseCommand):
|
|||||||
help = f"Prune all persistent content older then {settings.APPRISE_STORAGE_PRUNE_DAYS} days()"
|
help = f"Prune all persistent content older then {settings.APPRISE_STORAGE_PRUNE_DAYS} days()"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("-d", "--days", type=int, default=settings.APPRISE_STORAGE_PRUNE_DAYS)
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--days",
|
||||||
|
type=int,
|
||||||
|
default=settings.APPRISE_STORAGE_PRUNE_DAYS,
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Persistent Storage cleanup
|
# Persistent Storage cleanup
|
||||||
apprise.PersistentStore.disk_prune(
|
apprise.PersistentStore.disk_prune(
|
||||||
path=settings.APPRISE_STORAGE_DIR,
|
path=settings.APPRISE_STORAGE_DIR,
|
||||||
expires=options["days"] * 86400, action=True,
|
expires=options["days"] * 86400,
|
||||||
)
|
action=True,
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS('Successfully pruned persistent storeage (days: %d)' % options["days"])
|
|
||||||
)
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Successfully pruned persistent storeage (days: {options['days']})"))
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,13 +21,13 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from api.forms import NotifyForm
|
|
||||||
|
|
||||||
# import the logging library
|
# import the logging library
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from api.forms import NotifyForm
|
||||||
|
|
||||||
# Get an instance of a logger
|
# Get an instance of a logger
|
||||||
logger = logging.getLogger('django')
|
logger = logging.getLogger("django")
|
||||||
|
|
||||||
|
|
||||||
def remap_fields(rules, payload, form=None):
|
def remap_fields(rules, payload, form=None):
|
||||||
@@ -54,7 +53,6 @@ def remap_fields(rules, payload, form=None):
|
|||||||
# First generate our expected keys; only these can be mapped
|
# First generate our expected keys; only these can be mapped
|
||||||
expected_keys = set(form.fields.keys())
|
expected_keys = set(form.fields.keys())
|
||||||
for _key, value in rules.items():
|
for _key, value in rules.items():
|
||||||
|
|
||||||
key = _key.lower()
|
key = _key.lower()
|
||||||
if key in payload and not value:
|
if key in payload and not value:
|
||||||
# Remove element
|
# Remove element
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,24 +21,26 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.test import SimpleTestCase
|
|
||||||
from django.core.exceptions import RequestDataTooBig
|
|
||||||
from apprise import ConfigFormat
|
|
||||||
from unittest.mock import patch
|
|
||||||
from unittest import mock
|
|
||||||
from django.test.utils import override_settings
|
|
||||||
from ..forms import AUTO_DETECT_CONFIG_KEYWORD
|
|
||||||
import json
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
|
from unittest import mock
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.exceptions import RequestDataTooBig
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
from apprise import ConfigFormat
|
||||||
|
|
||||||
|
from ..forms import AUTO_DETECT_CONFIG_KEYWORD
|
||||||
|
|
||||||
|
|
||||||
class AddTests(SimpleTestCase):
|
class AddTests(SimpleTestCase):
|
||||||
|
|
||||||
def test_add_invalid_key_status_code(self):
|
def test_add_invalid_key_status_code(self):
|
||||||
"""
|
"""
|
||||||
Test GET requests to invalid key
|
Test GET requests to invalid key
|
||||||
"""
|
"""
|
||||||
response = self.client.get('/add/**invalid-key**')
|
response = self.client.get("/add/**invalid-key**")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_key_lengths(self):
|
def test_key_lengths(self):
|
||||||
@@ -49,22 +50,22 @@ class AddTests(SimpleTestCase):
|
|||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
h = hashlib.sha512()
|
h = hashlib.sha512()
|
||||||
h.update(b'string')
|
h.update(b"string")
|
||||||
key = h.hexdigest()
|
key = h.hexdigest()
|
||||||
|
|
||||||
# Our limit
|
# Our limit
|
||||||
assert len(key) == 128
|
assert len(key) == 128
|
||||||
|
|
||||||
# Add our URL
|
# Add our URL
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
|
||||||
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# However adding just 1 more character exceeds our limit and the save
|
# However adding just 1 more character exceeds our limit and the save
|
||||||
# will fail
|
# will fail
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key + 'x'),
|
"/add/{}".format(key + "x"),
|
||||||
{'urls': 'mailto://user:pass@yahoo.ca'})
|
{"urls": "mailto://user:pass@yahoo.ca"},
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
@override_settings(APPRISE_CONFIG_LOCK=True)
|
@override_settings(APPRISE_CONFIG_LOCK=True)
|
||||||
@@ -73,11 +74,10 @@ class AddTests(SimpleTestCase):
|
|||||||
Test adding a configuration by URLs with lock set won't work
|
Test adding a configuration by URLs with lock set won't work
|
||||||
"""
|
"""
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_save_config_by_urls_with_lock'
|
key = "test_save_config_by_urls_with_lock"
|
||||||
|
|
||||||
# We simply do not have permission to do so
|
# We simply do not have permission to do so
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
|
||||||
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
def test_save_config_by_urls(self):
|
def test_save_config_by_urls(self):
|
||||||
@@ -86,68 +86,68 @@ class AddTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_save_config_by_urls'
|
key = "test_save_config_by_urls"
|
||||||
|
|
||||||
# GET returns 405 (not allowed)
|
# GET returns 405 (not allowed)
|
||||||
response = self.client.get('/add/{}'.format(key))
|
response = self.client.get("/add/{}".format(key))
|
||||||
assert response.status_code == 405
|
assert response.status_code == 405
|
||||||
|
|
||||||
# no data
|
# no data
|
||||||
response = self.client.post('/add/{}'.format(key))
|
response = self.client.post("/add/{}".format(key))
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# No entries specified
|
# No entries specified
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"urls": ""})
|
||||||
'/add/{}'.format(key), {'urls': ''})
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Added successfully
|
# Added successfully
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
|
||||||
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# No URLs loaded
|
# No URLs loaded
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
{'config': 'invalid content', 'format': 'text'})
|
{"config": "invalid content", "format": "text"},
|
||||||
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Test a case where we fail to load a valid configuration file
|
# Test a case where we fail to load a valid configuration file
|
||||||
with patch('apprise.AppriseConfig.add', return_value=False):
|
with patch("apprise.AppriseConfig.add", return_value=False):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
{'config': 'garbage://', 'format': 'text'})
|
{"config": "garbage://", "format": "text"},
|
||||||
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
with patch('os.remove', side_effect=OSError):
|
with patch("os.remove", side_effect=OSError):
|
||||||
# We will fail to remove the device first prior to placing a new
|
# We will fail to remove the device first prior to placing a new
|
||||||
# one; This will result in a 500 error
|
# one; This will result in a 500 error
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key), {
|
"/add/{}".format(key),
|
||||||
'urls': 'mailto://user:newpass@gmail.com'})
|
{"urls": "mailto://user:newpass@gmail.com"},
|
||||||
|
)
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
|
|
||||||
# URL is actually not a valid one (invalid Slack tokens specified
|
# URL is actually not a valid one (invalid Slack tokens specified
|
||||||
# below)
|
# below)
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"urls": "slack://-/-/-"})
|
||||||
'/add/{}'.format(key), {'urls': 'slack://-/-/-'})
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Test with JSON
|
# Test with JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
|
data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
with mock.patch('json.loads') as mock_loads:
|
with mock.patch("json.loads") as mock_loads:
|
||||||
mock_loads.side_effect = RequestDataTooBig()
|
mock_loads.side_effect = RequestDataTooBig()
|
||||||
# Send our notification by specifying the tag in the parameters
|
# Send our notification by specifying the tag in the parameters
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
|
data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Our notification failed
|
# Our notification failed
|
||||||
@@ -155,49 +155,49 @@ class AddTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Test with JSON (and no payload provided)
|
# Test with JSON (and no payload provided)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({}),
|
data=json.dumps({}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Test with XML which simply isn't supported
|
# Test with XML which simply isn't supported
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data='<urls><url>mailto://user:pass@yahoo.ca</url></urls>',
|
data="<urls><url>mailto://user:pass@yahoo.ca</url></urls>",
|
||||||
content_type='application/xml',
|
content_type="application/xml",
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Invalid JSON
|
# Invalid JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data='{',
|
data="{",
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Test the handling of underlining disk/write exceptions
|
# Test the handling of underlining disk/write exceptions
|
||||||
with patch('os.makedirs') as mock_mkdirs:
|
with patch("os.makedirs") as mock_mkdirs:
|
||||||
mock_mkdirs.side_effect = OSError()
|
mock_mkdirs.side_effect = OSError()
|
||||||
# We'll fail to write our key now
|
# We'll fail to write our key now
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
|
data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# internal errors are correctly identified
|
# internal errors are correctly identified
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
|
|
||||||
# Test the handling of underlining disk/write exceptions
|
# Test the handling of underlining disk/write exceptions
|
||||||
with patch('gzip.open') as mock_open:
|
with patch("gzip.open") as mock_open:
|
||||||
mock_open.side_effect = OSError()
|
mock_open.side_effect = OSError()
|
||||||
# We'll fail to write our key now
|
# We'll fail to write our key now
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
|
data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# internal errors are correctly identified
|
# internal errors are correctly identified
|
||||||
@@ -209,15 +209,16 @@ class AddTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_save_config_by_config'
|
key = "test_save_config_by_config"
|
||||||
|
|
||||||
# Empty Text Configuration
|
# Empty Text Configuration
|
||||||
config = """
|
config = """
|
||||||
|
|
||||||
""" # noqa W293
|
"""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key), {
|
"/add/{}".format(key),
|
||||||
'format': ConfigFormat.TEXT, 'config': config})
|
{"format": ConfigFormat.TEXT.value, "config": config},
|
||||||
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Valid Text Configuration
|
# Valid Text Configuration
|
||||||
@@ -226,15 +227,16 @@ class AddTests(SimpleTestCase):
|
|||||||
home=mailto://user:pass@hotmail.com
|
home=mailto://user:pass@hotmail.com
|
||||||
"""
|
"""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
{'format': ConfigFormat.TEXT, 'config': config})
|
{"format": ConfigFormat.TEXT.value, "config": config},
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Test with JSON
|
# Test with JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({'format': ConfigFormat.TEXT, 'config': config}),
|
data=json.dumps({"format": ConfigFormat.TEXT.value, "config": config}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -247,35 +249,35 @@ class AddTests(SimpleTestCase):
|
|||||||
tag: home
|
tag: home
|
||||||
"""
|
"""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
{'format': ConfigFormat.YAML, 'config': config})
|
{"format": ConfigFormat.YAML.value, "config": config},
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Test with JSON
|
# Test with JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({'format': ConfigFormat.YAML, 'config': config}),
|
data=json.dumps({"format": ConfigFormat.YAML.value, "config": config}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Test invalid config format
|
# Test invalid config format
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({'format': 'INVALID', 'config': config}),
|
data=json.dumps({"format": "INVALID", "config": config}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Test the handling of underlining disk/write exceptions
|
# Test the handling of underlining disk/write exceptions
|
||||||
with patch('gzip.open') as mock_open:
|
with patch("gzip.open") as mock_open:
|
||||||
mock_open.side_effect = OSError()
|
mock_open.side_effect = OSError()
|
||||||
# We'll fail to write our key now
|
# We'll fail to write our key now
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps(
|
data=json.dumps({"format": ConfigFormat.YAML.value, "config": config}),
|
||||||
{'format': ConfigFormat.YAML, 'config': config}),
|
content_type="application/json",
|
||||||
content_type='application/json',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# internal errors are correctly identified
|
# internal errors are correctly identified
|
||||||
@@ -287,15 +289,16 @@ class AddTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_save_auto_detect_config_format'
|
key = "test_save_auto_detect_config_format"
|
||||||
|
|
||||||
# Empty Text Configuration
|
# Empty Text Configuration
|
||||||
config = """
|
config = """
|
||||||
|
|
||||||
""" # noqa W293
|
"""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key), {
|
"/add/{}".format(key),
|
||||||
'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config})
|
{"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config},
|
||||||
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Valid Text Configuration
|
# Valid Text Configuration
|
||||||
@@ -304,15 +307,16 @@ class AddTests(SimpleTestCase):
|
|||||||
home=mailto://user:pass@hotmail.com
|
home=mailto://user:pass@hotmail.com
|
||||||
"""
|
"""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config})
|
{"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config},
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Test with JSON
|
# Test with JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({'format': ConfigFormat.TEXT, 'config': config}),
|
data=json.dumps({"format": ConfigFormat.TEXT.value, "config": config}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -326,16 +330,16 @@ class AddTests(SimpleTestCase):
|
|||||||
tag: home
|
tag: home
|
||||||
"""
|
"""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config})
|
{"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config},
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Test with JSON
|
# Test with JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps(
|
data=json.dumps({"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}),
|
||||||
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}),
|
content_type="application/json",
|
||||||
content_type='application/json',
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -344,16 +348,16 @@ class AddTests(SimpleTestCase):
|
|||||||
42
|
42
|
||||||
"""
|
"""
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config})
|
{"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config},
|
||||||
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
# Test with JSON
|
# Test with JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps(
|
data=json.dumps({"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}),
|
||||||
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}),
|
content_type="application/json",
|
||||||
content_type='application/json',
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
@@ -363,11 +367,11 @@ class AddTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_save_with_bad_input'
|
key = "test_save_with_bad_input"
|
||||||
# Test with JSON
|
# Test with JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key),
|
"/add/{}".format(key),
|
||||||
data=json.dumps({'garbage': 'input'}),
|
data=json.dumps({"garbage": "input"}),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,24 +21,25 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
import requests
|
|
||||||
from django.test import SimpleTestCase
|
|
||||||
from unittest import mock
|
|
||||||
from unittest.mock import mock_open
|
|
||||||
from ..utils import Attachment, HTTPAttachment
|
|
||||||
from ..utils import parse_attachments
|
|
||||||
from ..urlfilter import AppriseURLFilter
|
|
||||||
from .. import utils
|
|
||||||
from django.test.utils import override_settings
|
|
||||||
from django.conf import settings
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
from shutil import rmtree
|
|
||||||
import base64
|
import base64
|
||||||
from unittest.mock import patch
|
from contextlib import suppress
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from os.path import dirname, getsize, join
|
||||||
from os.path import dirname, join, getsize
|
from shutil import rmtree
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest import mock
|
||||||
|
from unittest.mock import mock_open, patch
|
||||||
|
|
||||||
SAMPLE_FILE = join(dirname(dirname(dirname(__file__))), 'static', 'logo.png')
|
from django.conf import settings
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .. import utils
|
||||||
|
from ..urlfilter import AppriseURLFilter
|
||||||
|
from ..utils import Attachment, HTTPAttachment, parse_attachments
|
||||||
|
|
||||||
|
SAMPLE_FILE = join(dirname(dirname(dirname(__file__))), "static", "logo.png")
|
||||||
|
|
||||||
|
|
||||||
class AttachmentTests(SimpleTestCase):
|
class AttachmentTests(SimpleTestCase):
|
||||||
@@ -48,12 +48,9 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
self.tmp_dir = TemporaryDirectory()
|
self.tmp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
# Clear directory
|
# Clear content if possible
|
||||||
try:
|
with suppress(FileNotFoundError):
|
||||||
rmtree(self.tmp_dir.name)
|
rmtree(self.tmp_dir.name)
|
||||||
except FileNotFoundError:
|
|
||||||
# no worries
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.tmp_dir = None
|
self.tmp_dir = None
|
||||||
|
|
||||||
@@ -63,32 +60,32 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
with override_settings(APPRISE_ATTACH_DIR=self.tmp_dir.name):
|
with override_settings(APPRISE_ATTACH_DIR=self.tmp_dir.name):
|
||||||
with mock.patch('os.makedirs', side_effect=OSError):
|
with mock.patch("os.makedirs", side_effect=OSError):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
Attachment('file')
|
Attachment("file")
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
HTTPAttachment('web')
|
HTTPAttachment("web")
|
||||||
|
|
||||||
with mock.patch('tempfile.mkstemp', side_effect=FileNotFoundError):
|
with mock.patch("tempfile.mkstemp", side_effect=FileNotFoundError):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
Attachment('file')
|
Attachment("file")
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
HTTPAttachment('web')
|
HTTPAttachment("web")
|
||||||
|
|
||||||
with mock.patch('os.remove', side_effect=FileNotFoundError):
|
with mock.patch("os.remove", side_effect=FileNotFoundError):
|
||||||
a = Attachment('file')
|
a = Attachment("file")
|
||||||
# Force __del__ call to throw an exception which we gracefully
|
# Force __del__ call to throw an exception which we gracefully
|
||||||
# handle
|
# handle
|
||||||
del a
|
del a
|
||||||
|
|
||||||
a = HTTPAttachment('web')
|
a = HTTPAttachment("web")
|
||||||
a._path = 'abcd'
|
a._path = "abcd"
|
||||||
assert a.filename == 'web'
|
assert a.filename == "web"
|
||||||
# Force __del__ call to throw an exception which we gracefully
|
# Force __del__ call to throw an exception which we gracefully
|
||||||
# handle
|
# handle
|
||||||
del a
|
del a
|
||||||
|
|
||||||
a = Attachment('file')
|
a = Attachment("file")
|
||||||
assert a.filename
|
assert a.filename
|
||||||
|
|
||||||
def test_form_file_attachment_parsing(self):
|
def test_form_file_attachment_parsing(self):
|
||||||
@@ -114,19 +111,13 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
assert len(result) == 0
|
assert len(result) == 0
|
||||||
|
|
||||||
# Get ourselves a file to work with
|
# Get ourselves a file to work with
|
||||||
files_request = {
|
files_request = {"file1": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")}
|
||||||
'file1': SimpleUploadedFile(
|
|
||||||
"attach.txt", b"content here", content_type="text/plain")
|
|
||||||
}
|
|
||||||
result = parse_attachments(None, files_request)
|
result = parse_attachments(None, files_request)
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
|
||||||
# Test case where no filename was specified
|
# Test case where no filename was specified
|
||||||
files_request = {
|
files_request = {"file1": SimpleUploadedFile(" ", b"content here", content_type="text/plain")}
|
||||||
'file1': SimpleUploadedFile(
|
|
||||||
" ", b"content here", content_type="text/plain")
|
|
||||||
}
|
|
||||||
result = parse_attachments(None, files_request)
|
result = parse_attachments(None, files_request)
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -135,19 +126,19 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
# attachment to disk
|
# attachment to disk
|
||||||
m = mock_open()
|
m = mock_open()
|
||||||
m.side_effect = OSError()
|
m.side_effect = OSError()
|
||||||
with patch('builtins.open', m):
|
with patch("builtins.open", m), self.assertRaises(ValueError):
|
||||||
with self.assertRaises(ValueError):
|
parse_attachments(None, files_request)
|
||||||
parse_attachments(None, files_request)
|
|
||||||
|
|
||||||
# Test a case where our attachment exceeds the maximum size we allow
|
# Test a case where our attachment exceeds the maximum size we allow
|
||||||
# for
|
# for
|
||||||
with override_settings(APPRISE_ATTACH_SIZE=1):
|
with override_settings(APPRISE_ATTACH_SIZE=1):
|
||||||
files_request = {
|
files_request = {
|
||||||
'file1': SimpleUploadedFile(
|
"file1": SimpleUploadedFile(
|
||||||
"attach.txt",
|
"attach.txt",
|
||||||
# More then 1 MB in size causing error to trip
|
# More then 1 MB in size causing error to trip
|
||||||
("content" * 1024 * 1024).encode('utf-8'),
|
("content" * 1024 * 1024).encode("utf-8"),
|
||||||
content_type="text/plain")
|
content_type="text/plain",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(None, files_request)
|
parse_attachments(None, files_request)
|
||||||
@@ -155,24 +146,22 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
# Test Attachment Size seto t zer0
|
# Test Attachment Size seto t zer0
|
||||||
with override_settings(APPRISE_ATTACH_SIZE=0):
|
with override_settings(APPRISE_ATTACH_SIZE=0):
|
||||||
files_request = {
|
files_request = {
|
||||||
'file1': SimpleUploadedFile(
|
"file1": SimpleUploadedFile(
|
||||||
"attach.txt",
|
"attach.txt",
|
||||||
# More then 1 MB in size causing error to trip
|
# More then 1 MB in size causing error to trip
|
||||||
("content" * 1024 * 1024).encode('utf-8'),
|
("content" * 1024 * 1024).encode("utf-8"),
|
||||||
content_type="text/plain")
|
content_type="text/plain",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(None, files_request)
|
parse_attachments(None, files_request)
|
||||||
|
|
||||||
# Bad data provided in filename field
|
# Bad data provided in filename field
|
||||||
files_request = {
|
files_request = {"file1": SimpleUploadedFile(None, b"content here", content_type="text/plain")}
|
||||||
'file1': SimpleUploadedFile(
|
|
||||||
None, b"content here", content_type="text/plain")
|
|
||||||
}
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(None, files_request)
|
parse_attachments(None, files_request)
|
||||||
|
|
||||||
@patch('requests.get')
|
@patch("requests.get")
|
||||||
def test_direct_attachment_parsing(self, mock_get):
|
def test_direct_attachment_parsing(self, mock_get):
|
||||||
"""
|
"""
|
||||||
Test the parsing of file attachments
|
Test the parsing of file attachments
|
||||||
@@ -187,33 +176,33 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
response.status_code = requests.codes.ok
|
response.status_code = requests.codes.ok
|
||||||
response.raise_for_status.return_value = True
|
response.raise_for_status.return_value = True
|
||||||
response.headers = {
|
response.headers = {
|
||||||
'Content-Length': getsize(SAMPLE_FILE),
|
"Content-Length": getsize(SAMPLE_FILE),
|
||||||
}
|
}
|
||||||
ref = {
|
ref = {
|
||||||
'io': None,
|
"io": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def iter_content(chunk_size=1024, *args, **kwargs):
|
def iter_content(chunk_size=1024, *args, **kwargs):
|
||||||
if not ref['io']:
|
if not ref["io"]:
|
||||||
ref['io'] = open(SAMPLE_FILE, 'rb')
|
ref["io"] = open(SAMPLE_FILE, "rb") # noqa: SIM115
|
||||||
block = ref['io'].read(chunk_size)
|
block = ref["io"].read(chunk_size)
|
||||||
if not block:
|
if not block:
|
||||||
# Close for re-use
|
# Close for re-use
|
||||||
ref['io'].close()
|
ref["io"].close()
|
||||||
ref['io'] = None
|
ref["io"] = None
|
||||||
yield block
|
yield block
|
||||||
|
|
||||||
response.iter_content = iter_content
|
response.iter_content = iter_content
|
||||||
|
|
||||||
def test(*args, **kwargs):
|
def test(*args, **kwargs):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
response.__enter__ = test
|
response.__enter__ = test
|
||||||
response.__exit__ = test
|
response.__exit__ = test
|
||||||
mock_get.return_value = response
|
mock_get.return_value = response
|
||||||
|
|
||||||
# Support base64 encoding
|
# Support base64 encoding
|
||||||
attachment_payload = {
|
attachment_payload = {"base64": base64.b64encode(b"data to be encoded").decode("utf-8")}
|
||||||
'base64': base64.b64encode(b'data to be encoded').decode('utf-8')
|
|
||||||
}
|
|
||||||
result = parse_attachments(attachment_payload, {})
|
result = parse_attachments(attachment_payload, {})
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -221,15 +210,14 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
# Support multi entries
|
# Support multi entries
|
||||||
attachment_payload = [
|
attachment_payload = [
|
||||||
{
|
{
|
||||||
'base64': base64.b64encode(
|
"base64": base64.b64encode(b"data to be encoded 1").decode("utf-8"),
|
||||||
b'data to be encoded 1').decode('utf-8'),
|
},
|
||||||
}, {
|
{
|
||||||
'base64': base64.b64encode(
|
"base64": base64.b64encode(b"data to be encoded 2").decode("utf-8"),
|
||||||
b'data to be encoded 2').decode('utf-8'),
|
},
|
||||||
}, {
|
{
|
||||||
'base64': base64.b64encode(
|
"base64": base64.b64encode(b"data to be encoded 3").decode("utf-8"),
|
||||||
b'data to be encoded 3').decode('utf-8'),
|
},
|
||||||
}
|
|
||||||
]
|
]
|
||||||
result = parse_attachments(attachment_payload, {})
|
result = parse_attachments(attachment_payload, {})
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
@@ -238,21 +226,24 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
# Support multi entries
|
# Support multi entries
|
||||||
attachment_payload = [
|
attachment_payload = [
|
||||||
{
|
{
|
||||||
'url': 'http://myserver/my.attachment.3',
|
"url": "http://myserver/my.attachment.3",
|
||||||
}, {
|
},
|
||||||
'url': 'http://myserver/my.attachment.2',
|
{
|
||||||
}, {
|
"url": "http://myserver/my.attachment.2",
|
||||||
'url': 'http://myserver/my.attachment.1',
|
},
|
||||||
}
|
{
|
||||||
|
"url": "http://myserver/my.attachment.1",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
result = parse_attachments(attachment_payload, {})
|
result = parse_attachments(attachment_payload, {})
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 3
|
assert len(result) == 3
|
||||||
|
|
||||||
with override_settings(APPRISE_ATTACH_DENY_URLS='*'):
|
with override_settings(APPRISE_ATTACH_DENY_URLS="*"):
|
||||||
utils.ATTACH_URL_FILTER = AppriseURLFilter(
|
utils.ATTACH_URL_FILTER = AppriseURLFilter(
|
||||||
settings.APPRISE_ATTACH_ALLOW_URLS,
|
settings.APPRISE_ATTACH_ALLOW_URLS,
|
||||||
settings.APPRISE_ATTACH_DENY_URLS)
|
settings.APPRISE_ATTACH_DENY_URLS,
|
||||||
|
)
|
||||||
|
|
||||||
# We will fail to parse our URL based attachment
|
# We will fail to parse our URL based attachment
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@@ -261,7 +252,8 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
# Reload our configuration to default values
|
# Reload our configuration to default values
|
||||||
utils.ATTACH_URL_FILTER = AppriseURLFilter(
|
utils.ATTACH_URL_FILTER = AppriseURLFilter(
|
||||||
settings.APPRISE_ATTACH_ALLOW_URLS,
|
settings.APPRISE_ATTACH_ALLOW_URLS,
|
||||||
settings.APPRISE_ATTACH_DENY_URLS)
|
settings.APPRISE_ATTACH_DENY_URLS,
|
||||||
|
)
|
||||||
|
|
||||||
# Garbage handling (integer, float, object, etc is invalid)
|
# Garbage handling (integer, float, object, etc is invalid)
|
||||||
attachment_payload = 5
|
attachment_payload = 5
|
||||||
@@ -279,8 +271,8 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
|
|
||||||
# filename provided, but its empty (and/or contains whitespace)
|
# filename provided, but its empty (and/or contains whitespace)
|
||||||
attachment_payload = {
|
attachment_payload = {
|
||||||
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
|
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
|
||||||
'filename': ' '
|
"filename": " ",
|
||||||
}
|
}
|
||||||
result = parse_attachments(attachment_payload, {})
|
result = parse_attachments(attachment_payload, {})
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
@@ -288,30 +280,30 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
|
|
||||||
# filename too long
|
# filename too long
|
||||||
attachment_payload = {
|
attachment_payload = {
|
||||||
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
|
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
|
||||||
'filename': 'a' * 1000,
|
"filename": "a" * 1000,
|
||||||
}
|
}
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(attachment_payload, {})
|
parse_attachments(attachment_payload, {})
|
||||||
|
|
||||||
# filename invalid
|
# filename invalid
|
||||||
attachment_payload = {
|
attachment_payload = {
|
||||||
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
|
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
|
||||||
'filename': 1,
|
"filename": 1,
|
||||||
}
|
}
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(attachment_payload, {})
|
parse_attachments(attachment_payload, {})
|
||||||
|
|
||||||
attachment_payload = {
|
attachment_payload = {
|
||||||
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
|
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
|
||||||
'filename': None,
|
"filename": None,
|
||||||
}
|
}
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(attachment_payload, {})
|
parse_attachments(attachment_payload, {})
|
||||||
|
|
||||||
attachment_payload = {
|
attachment_payload = {
|
||||||
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
|
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
|
||||||
'filename': object(),
|
"filename": object(),
|
||||||
}
|
}
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(attachment_payload, {})
|
parse_attachments(attachment_payload, {})
|
||||||
@@ -332,19 +324,18 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
|
|
||||||
# We allow empty entries, this is okay; there is just nothing
|
# We allow empty entries, this is okay; there is just nothing
|
||||||
# returned at the end of the day
|
# returned at the end of the day
|
||||||
assert parse_attachments({''}, {}) == []
|
assert parse_attachments({""}, {}) == []
|
||||||
|
|
||||||
# We can't parse entries that are not base64 but specified as
|
# We can't parse entries that are not base64 but specified as
|
||||||
# though they are
|
# though they are
|
||||||
attachment_payload = {
|
attachment_payload = {
|
||||||
'base64': 'not-base-64',
|
"base64": "not-base-64",
|
||||||
}
|
}
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(attachment_payload, {})
|
parse_attachments(attachment_payload, {})
|
||||||
|
|
||||||
# Support string; these become web requests
|
# Support string; these become web requests
|
||||||
attachment_payload = \
|
attachment_payload = "https://avatars.githubusercontent.com/u/850374?v=4"
|
||||||
"https://avatars.githubusercontent.com/u/850374?v=4"
|
|
||||||
result = parse_attachments(attachment_payload, {})
|
result = parse_attachments(attachment_payload, {})
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -364,17 +355,15 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
# to disk
|
# to disk
|
||||||
m = mock_open()
|
m = mock_open()
|
||||||
m.side_effect = OSError()
|
m.side_effect = OSError()
|
||||||
with patch('builtins.open', m):
|
with patch("builtins.open", m), self.assertRaises(ValueError):
|
||||||
with self.assertRaises(ValueError):
|
attachment_payload = b"some data to work with."
|
||||||
attachment_payload = b"some data to work with."
|
parse_attachments(attachment_payload, {})
|
||||||
parse_attachments(attachment_payload, {})
|
|
||||||
|
|
||||||
# Test a case where our attachment exceeds the maximum size we allow
|
# Test a case where our attachment exceeds the maximum size we allow
|
||||||
# for
|
# for
|
||||||
with override_settings(APPRISE_ATTACH_SIZE=1):
|
with override_settings(APPRISE_ATTACH_SIZE=1):
|
||||||
# More then 1 MB in size causing error to trip
|
# More then 1 MB in size causing error to trip
|
||||||
attachment_payload = \
|
attachment_payload = ("content" * 1024 * 1024).encode("utf-8")
|
||||||
("content" * 1024 * 1024).encode('utf-8')
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(attachment_payload, {})
|
parse_attachments(attachment_payload, {})
|
||||||
|
|
||||||
@@ -387,7 +376,7 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
attachment_payload = [
|
attachment_payload = [
|
||||||
# Request several images
|
# Request several images
|
||||||
"https://myserver/myotherfile.png",
|
"https://myserver/myotherfile.png",
|
||||||
"https://myserver/myfile.png"
|
"https://myserver/myfile.png",
|
||||||
]
|
]
|
||||||
result = parse_attachments(attachment_payload, {})
|
result = parse_attachments(attachment_payload, {})
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
@@ -411,12 +400,13 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
# We have hosts that will be blocked
|
# We have hosts that will be blocked
|
||||||
parse_attachments([ap], {})
|
parse_attachments([ap], {})
|
||||||
|
|
||||||
attachment_payload = [{
|
attachment_payload = [
|
||||||
# Request several images
|
{
|
||||||
'url': "https://myserver/myotherfile.png",
|
# Request several images
|
||||||
}, {
|
"url": "https://myserver/myotherfile.png",
|
||||||
'url': "https://myserver/myfile.png"
|
},
|
||||||
}]
|
{"url": "https://myserver/myfile.png"},
|
||||||
|
]
|
||||||
result = parse_attachments(attachment_payload, {})
|
result = parse_attachments(attachment_payload, {})
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
@@ -447,10 +437,13 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
parse_attachments(attachment_payload, {})
|
parse_attachments(attachment_payload, {})
|
||||||
|
|
||||||
# Support url encoding
|
# Support url encoding
|
||||||
attachment_payload = [{
|
attachment_payload = [
|
||||||
'url': "https://myserver/garbage/abcd1.png",
|
{
|
||||||
}, {
|
"url": "https://myserver/garbage/abcd1.png",
|
||||||
'url': "https://myserver/garbage/abcd2.png",
|
},
|
||||||
}]
|
{
|
||||||
|
"url": "https://myserver/garbage/abcd2.png",
|
||||||
|
},
|
||||||
|
]
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parse_attachments(attachment_payload, {})
|
parse_attachments(attachment_payload, {})
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
@@ -24,12 +23,12 @@
|
|||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from django.test import SimpleTestCase
|
|
||||||
from django.core import management
|
from django.core import management
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
|
||||||
class CommandTests(SimpleTestCase):
|
class CommandTests(SimpleTestCase):
|
||||||
|
|
||||||
def test_command_style(self):
|
def test_command_style(self):
|
||||||
out = io.StringIO()
|
out = io.StringIO()
|
||||||
management.call_command('storeprune', days=40, stdout=out)
|
management.call_command("storeprune", days=40, stdout=out)
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,38 +21,36 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
import os
|
|
||||||
|
|
||||||
from ..utils import AppriseConfigCache
|
|
||||||
from ..utils import AppriseStoreMode
|
|
||||||
from ..utils import SimpleFileExtension
|
|
||||||
from apprise import ConfigFormat
|
|
||||||
from unittest.mock import patch
|
|
||||||
from unittest.mock import mock_open
|
|
||||||
import errno
|
import errno
|
||||||
|
import os
|
||||||
|
from unittest.mock import mock_open, patch
|
||||||
|
|
||||||
|
from apprise import ConfigFormat
|
||||||
|
|
||||||
|
from ..utils import AppriseConfigCache, AppriseStoreMode, SimpleFileExtension
|
||||||
|
|
||||||
|
|
||||||
def test_apprise_config_io_hash_mode(tmpdir):
|
def test_apprise_config_io_hash_mode(tmpdir):
|
||||||
"""
|
"""
|
||||||
Test Apprise Config Disk Put/Get using HASH mode
|
Test Apprise Config Disk Put/Get using HASH mode
|
||||||
"""
|
"""
|
||||||
content = 'mailto://test:pass@gmail.com'
|
content = "mailto://test:pass@gmail.com"
|
||||||
key = 'test_apprise_config_io_hash'
|
key = "test_apprise_config_io_hash"
|
||||||
|
|
||||||
# Create our object to work with
|
# Create our object to work with
|
||||||
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH)
|
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH)
|
||||||
|
|
||||||
# Verify that the content doesn't already exist
|
# Verify that the content doesn't already exist
|
||||||
assert acc_obj.get(key) == (None, '')
|
assert acc_obj.get(key) == (None, "")
|
||||||
|
|
||||||
# Write our content assigned to our key
|
# Write our content assigned to our key
|
||||||
assert acc_obj.put(key, content, ConfigFormat.TEXT)
|
assert acc_obj.put(key, content, ConfigFormat.TEXT.value)
|
||||||
|
|
||||||
# Test the handling of underlining disk/write exceptions
|
# Test the handling of underlining disk/write exceptions
|
||||||
with patch('gzip.open') as mock_gzopen:
|
with patch("gzip.open") as mock_gzopen:
|
||||||
mock_gzopen.side_effect = OSError()
|
mock_gzopen.side_effect = OSError()
|
||||||
# We'll fail to write our key now
|
# We'll fail to write our key now
|
||||||
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
|
assert not acc_obj.put(key, content, ConfigFormat.TEXT.value)
|
||||||
|
|
||||||
# Get path details
|
# Get path details
|
||||||
conf_dir, _ = acc_obj.path(key)
|
conf_dir, _ = acc_obj.path(key)
|
||||||
@@ -63,13 +60,13 @@ def test_apprise_config_io_hash_mode(tmpdir):
|
|||||||
|
|
||||||
# There should be just 1 new file in this directory
|
# There should be just 1 new file in this directory
|
||||||
assert len(contents) == 1
|
assert len(contents) == 1
|
||||||
assert contents[0].endswith('.{}'.format(ConfigFormat.TEXT))
|
assert contents[0].endswith(".{}".format(ConfigFormat.TEXT.value))
|
||||||
|
|
||||||
# Verify that the content is retrievable
|
# Verify that the content is retrievable
|
||||||
assert acc_obj.get(key) == (content, ConfigFormat.TEXT)
|
assert acc_obj.get(key) == (content, ConfigFormat.TEXT.value)
|
||||||
|
|
||||||
# Test the handling of underlining disk/read exceptions
|
# Test the handling of underlining disk/read exceptions
|
||||||
with patch('gzip.open') as mock_gzopen:
|
with patch("gzip.open") as mock_gzopen:
|
||||||
mock_gzopen.side_effect = OSError()
|
mock_gzopen.side_effect = OSError()
|
||||||
# We'll fail to read our key now
|
# We'll fail to read our key now
|
||||||
assert acc_obj.get(key) == (None, None)
|
assert acc_obj.get(key) == (None, None)
|
||||||
@@ -80,14 +77,14 @@ def test_apprise_config_io_hash_mode(tmpdir):
|
|||||||
# But the second time is okay as it no longer exists
|
# But the second time is okay as it no longer exists
|
||||||
assert acc_obj.clear(key) is None
|
assert acc_obj.clear(key) is None
|
||||||
|
|
||||||
with patch('os.remove') as mock_remove:
|
with patch("os.remove") as mock_remove:
|
||||||
mock_remove.side_effect = OSError(errno.EPERM)
|
mock_remove.side_effect = OSError(errno.EPERM)
|
||||||
# OSError
|
# OSError
|
||||||
assert acc_obj.clear(key) is False
|
assert acc_obj.clear(key) is False
|
||||||
|
|
||||||
# If we try to put the same file, we'll fail since
|
# If we try to put the same file, we'll fail since
|
||||||
# one exists there already
|
# one exists there already
|
||||||
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
|
assert not acc_obj.put(key, content, ConfigFormat.TEXT.value)
|
||||||
|
|
||||||
# Now test with YAML file
|
# Now test with YAML file
|
||||||
content = """
|
content = """
|
||||||
@@ -100,17 +97,17 @@ def test_apprise_config_io_hash_mode(tmpdir):
|
|||||||
# Write our content assigned to our key
|
# Write our content assigned to our key
|
||||||
# This should gracefully clear the TEXT entry that was
|
# This should gracefully clear the TEXT entry that was
|
||||||
# previously in the spot
|
# previously in the spot
|
||||||
assert acc_obj.put(key, content, ConfigFormat.YAML)
|
assert acc_obj.put(key, content, ConfigFormat.YAML.value)
|
||||||
|
|
||||||
# List content of directory
|
# List content of directory
|
||||||
contents = os.listdir(conf_dir)
|
contents = os.listdir(conf_dir)
|
||||||
|
|
||||||
# There should STILL be just 1 new file in this directory
|
# There should STILL be just 1 new file in this directory
|
||||||
assert len(contents) == 1
|
assert len(contents) == 1
|
||||||
assert contents[0].endswith('.{}'.format(ConfigFormat.YAML))
|
assert contents[0].endswith(".{}".format(ConfigFormat.YAML.value))
|
||||||
|
|
||||||
# Verify that the content is retrievable
|
# Verify that the content is retrievable
|
||||||
assert acc_obj.get(key) == (content, ConfigFormat.YAML)
|
assert acc_obj.get(key) == (content, ConfigFormat.YAML.value)
|
||||||
|
|
||||||
|
|
||||||
def test_apprise_config_list_simple_mode(tmpdir):
|
def test_apprise_config_list_simple_mode(tmpdir):
|
||||||
@@ -121,26 +118,26 @@ def test_apprise_config_list_simple_mode(tmpdir):
|
|||||||
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE)
|
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE)
|
||||||
|
|
||||||
# Add a hidden file to the config directory (which should be ignored)
|
# Add a hidden file to the config directory (which should be ignored)
|
||||||
hidden_file = os.path.join(str(tmpdir), '.hidden')
|
hidden_file = os.path.join(str(tmpdir), ".hidden")
|
||||||
with open(hidden_file, 'w') as f:
|
with open(hidden_file, "w") as f:
|
||||||
f.write('hidden file')
|
f.write("hidden file")
|
||||||
|
|
||||||
# Write 5 text configs and 5 yaml configs
|
# Write 5 text configs and 5 yaml configs
|
||||||
content_text = 'mailto://test:pass@gmail.com'
|
content_text = "mailto://test:pass@gmail.com"
|
||||||
content_yaml = """
|
content_yaml = """
|
||||||
version: 1
|
version: 1
|
||||||
urls:
|
urls:
|
||||||
- windows://
|
- windows://
|
||||||
"""
|
"""
|
||||||
text_key_tpl = 'test_apprise_config_list_simple_text_{}'
|
text_key_tpl = "test_apprise_config_list_simple_text_{}"
|
||||||
yaml_key_tpl = 'test_apprise_config_list_simple_yaml_{}'
|
yaml_key_tpl = "test_apprise_config_list_simple_yaml_{}"
|
||||||
text_keys = [text_key_tpl.format(i) for i in range(5)]
|
text_keys = [text_key_tpl.format(i) for i in range(5)]
|
||||||
yaml_keys = [yaml_key_tpl.format(i) for i in range(5)]
|
yaml_keys = [yaml_key_tpl.format(i) for i in range(5)]
|
||||||
key = None
|
key = None
|
||||||
for key in text_keys:
|
for key in text_keys:
|
||||||
assert acc_obj.put(key, content_text, ConfigFormat.TEXT)
|
assert acc_obj.put(key, content_text, ConfigFormat.TEXT.value)
|
||||||
for key in yaml_keys:
|
for key in yaml_keys:
|
||||||
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML)
|
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML.value)
|
||||||
|
|
||||||
# Ensure the 10 configuration files (plus the hidden file) are the only
|
# Ensure the 10 configuration files (plus the hidden file) are the only
|
||||||
# contents of the directory
|
# contents of the directory
|
||||||
@@ -161,26 +158,26 @@ def test_apprise_config_list_hash_mode(tmpdir):
|
|||||||
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH)
|
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH)
|
||||||
|
|
||||||
# Add a hidden file to the config directory (which should be ignored)
|
# Add a hidden file to the config directory (which should be ignored)
|
||||||
hidden_file = os.path.join(str(tmpdir), '.hidden')
|
hidden_file = os.path.join(str(tmpdir), ".hidden")
|
||||||
with open(hidden_file, 'w') as f:
|
with open(hidden_file, "w") as f:
|
||||||
f.write('hidden file')
|
f.write("hidden file")
|
||||||
|
|
||||||
# Write 5 text configs and 5 yaml configs
|
# Write 5 text configs and 5 yaml configs
|
||||||
content_text = 'mailto://test:pass@gmail.com'
|
content_text = "mailto://test:pass@gmail.com"
|
||||||
content_yaml = """
|
content_yaml = """
|
||||||
version: 1
|
version: 1
|
||||||
urls:
|
urls:
|
||||||
- windows://
|
- windows://
|
||||||
"""
|
"""
|
||||||
text_key_tpl = 'test_apprise_config_list_simple_text_{}'
|
text_key_tpl = "test_apprise_config_list_simple_text_{}"
|
||||||
yaml_key_tpl = 'test_apprise_config_list_simple_yaml_{}'
|
yaml_key_tpl = "test_apprise_config_list_simple_yaml_{}"
|
||||||
text_keys = [text_key_tpl.format(i) for i in range(5)]
|
text_keys = [text_key_tpl.format(i) for i in range(5)]
|
||||||
yaml_keys = [yaml_key_tpl.format(i) for i in range(5)]
|
yaml_keys = [yaml_key_tpl.format(i) for i in range(5)]
|
||||||
key = None
|
key = None
|
||||||
for key in text_keys:
|
for key in text_keys:
|
||||||
assert acc_obj.put(key, content_text, ConfigFormat.TEXT)
|
assert acc_obj.put(key, content_text, ConfigFormat.TEXT.value)
|
||||||
for key in yaml_keys:
|
for key in yaml_keys:
|
||||||
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML)
|
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML.value)
|
||||||
|
|
||||||
# Ensure the 10 configuration files (plus the hidden file) are the only
|
# Ensure the 10 configuration files (plus the hidden file) are the only
|
||||||
# contents of the directory
|
# contents of the directory
|
||||||
@@ -197,23 +194,23 @@ def test_apprise_config_io_simple_mode(tmpdir):
|
|||||||
"""
|
"""
|
||||||
Test Apprise Config Disk Put/Get using SIMPLE mode
|
Test Apprise Config Disk Put/Get using SIMPLE mode
|
||||||
"""
|
"""
|
||||||
content = 'mailto://test:pass@gmail.com'
|
content = "mailto://test:pass@gmail.com"
|
||||||
key = 'test_apprise_config_io_simple'
|
key = "test_apprise_config_io_simple"
|
||||||
|
|
||||||
# Create our object to work with
|
# Create our object to work with
|
||||||
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE)
|
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE)
|
||||||
|
|
||||||
# Verify that the content doesn't already exist
|
# Verify that the content doesn't already exist
|
||||||
assert acc_obj.get(key) == (None, '')
|
assert acc_obj.get(key) == (None, "")
|
||||||
|
|
||||||
# Write our content assigned to our key
|
# Write our content assigned to our key
|
||||||
assert acc_obj.put(key, content, ConfigFormat.TEXT)
|
assert acc_obj.put(key, content, ConfigFormat.TEXT.value)
|
||||||
|
|
||||||
m = mock_open()
|
m = mock_open()
|
||||||
m.side_effect = OSError()
|
m.side_effect = OSError()
|
||||||
with patch('builtins.open', m):
|
with patch("builtins.open", m):
|
||||||
# We'll fail to write our key now
|
# We'll fail to write our key now
|
||||||
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
|
assert not acc_obj.put(key, content, ConfigFormat.TEXT.value)
|
||||||
|
|
||||||
# Get path details
|
# Get path details
|
||||||
conf_dir, _ = acc_obj.path(key)
|
conf_dir, _ = acc_obj.path(key)
|
||||||
@@ -223,13 +220,13 @@ def test_apprise_config_io_simple_mode(tmpdir):
|
|||||||
|
|
||||||
# There should be just 1 new file in this directory
|
# There should be just 1 new file in this directory
|
||||||
assert len(contents) == 1
|
assert len(contents) == 1
|
||||||
assert contents[0].endswith('.{}'.format(SimpleFileExtension.TEXT))
|
assert contents[0].endswith(".{}".format(SimpleFileExtension.TEXT))
|
||||||
|
|
||||||
# Verify that the content is retrievable
|
# Verify that the content is retrievable
|
||||||
assert acc_obj.get(key) == (content, ConfigFormat.TEXT)
|
assert acc_obj.get(key) == (content, ConfigFormat.TEXT.value)
|
||||||
|
|
||||||
# Test the handling of underlining disk/read exceptions
|
# Test the handling of underlining disk/read exceptions
|
||||||
with patch('builtins.open', m) as mock__open:
|
with patch("builtins.open", m) as mock__open:
|
||||||
mock__open.side_effect = OSError()
|
mock__open.side_effect = OSError()
|
||||||
# We'll fail to read our key now
|
# We'll fail to read our key now
|
||||||
assert acc_obj.get(key) == (None, None)
|
assert acc_obj.get(key) == (None, None)
|
||||||
@@ -240,14 +237,14 @@ def test_apprise_config_io_simple_mode(tmpdir):
|
|||||||
# But the second time is okay as it no longer exists
|
# But the second time is okay as it no longer exists
|
||||||
assert acc_obj.clear(key) is None
|
assert acc_obj.clear(key) is None
|
||||||
|
|
||||||
with patch('os.remove') as mock_remove:
|
with patch("os.remove") as mock_remove:
|
||||||
mock_remove.side_effect = OSError(errno.EPERM)
|
mock_remove.side_effect = OSError(errno.EPERM)
|
||||||
# OSError
|
# OSError
|
||||||
assert acc_obj.clear(key) is False
|
assert acc_obj.clear(key) is False
|
||||||
|
|
||||||
# If we try to put the same file, we'll fail since
|
# If we try to put the same file, we'll fail since
|
||||||
# one exists there already
|
# one exists there already
|
||||||
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
|
assert not acc_obj.put(key, content, ConfigFormat.TEXT.value)
|
||||||
|
|
||||||
# Now test with YAML file
|
# Now test with YAML file
|
||||||
content = """
|
content = """
|
||||||
@@ -260,25 +257,25 @@ def test_apprise_config_io_simple_mode(tmpdir):
|
|||||||
# Write our content assigned to our key
|
# Write our content assigned to our key
|
||||||
# This should gracefully clear the TEXT entry that was
|
# This should gracefully clear the TEXT entry that was
|
||||||
# previously in the spot
|
# previously in the spot
|
||||||
assert acc_obj.put(key, content, ConfigFormat.YAML)
|
assert acc_obj.put(key, content, ConfigFormat.YAML.value)
|
||||||
|
|
||||||
# List content of directory
|
# List content of directory
|
||||||
contents = os.listdir(conf_dir)
|
contents = os.listdir(conf_dir)
|
||||||
|
|
||||||
# There should STILL be just 1 new file in this directory
|
# There should STILL be just 1 new file in this directory
|
||||||
assert len(contents) == 1
|
assert len(contents) == 1
|
||||||
assert contents[0].endswith('.{}'.format(SimpleFileExtension.YAML))
|
assert contents[0].endswith(".{}".format(SimpleFileExtension.YAML))
|
||||||
|
|
||||||
# Verify that the content is retrievable
|
# Verify that the content is retrievable
|
||||||
assert acc_obj.get(key) == (content, ConfigFormat.YAML)
|
assert acc_obj.get(key) == (content, ConfigFormat.YAML.value)
|
||||||
|
|
||||||
|
|
||||||
def test_apprise_config_io_disabled_mode(tmpdir):
|
def test_apprise_config_io_disabled_mode(tmpdir):
|
||||||
"""
|
"""
|
||||||
Test Apprise Config Disk Put/Get using DISABLED mode
|
Test Apprise Config Disk Put/Get using DISABLED mode
|
||||||
"""
|
"""
|
||||||
content = 'mailto://test:pass@gmail.com'
|
content = "mailto://test:pass@gmail.com"
|
||||||
key = 'test_apprise_config_io_disabled'
|
key = "test_apprise_config_io_disabled"
|
||||||
|
|
||||||
# Create our object to work with using an invalid mode
|
# Create our object to work with using an invalid mode
|
||||||
acc_obj = AppriseConfigCache(str(tmpdir), mode="invalid")
|
acc_obj = AppriseConfigCache(str(tmpdir), mode="invalid")
|
||||||
@@ -290,11 +287,11 @@ def test_apprise_config_io_disabled_mode(tmpdir):
|
|||||||
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.DISABLED)
|
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.DISABLED)
|
||||||
|
|
||||||
# Verify that the content doesn't already exist
|
# Verify that the content doesn't already exist
|
||||||
assert acc_obj.get(key) == (None, '')
|
assert acc_obj.get(key) == (None, "")
|
||||||
|
|
||||||
# Write our content assigned to our key
|
# Write our content assigned to our key
|
||||||
# This isn't allowed
|
# This isn't allowed
|
||||||
assert acc_obj.put(key, content, ConfigFormat.TEXT) is False
|
assert acc_obj.put(key, content, ConfigFormat.TEXT.value) is False
|
||||||
|
|
||||||
# Get path details
|
# Get path details
|
||||||
conf_dir, _ = acc_obj.path(key)
|
conf_dir, _ = acc_obj.path(key)
|
||||||
|
52
apprise_api/api/tests/test_config_middleware.py
Normal file
52
apprise_api/api/tests/test_config_middleware.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
# This code is licensed under the MIT License.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files(the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions :
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import RequestFactory, SimpleTestCase, override_settings
|
||||||
|
|
||||||
|
from apprise_api.core.middleware.config import DetectConfigMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class DetectConfigMiddlewareTest(SimpleTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
@override_settings(APPRISE_DEFAULT_CONFIG_ID="")
|
||||||
|
def test_middleware_falls_back_when_config_absent(self):
|
||||||
|
"""
|
||||||
|
Ensure we trigger the `if not config:` fallback
|
||||||
|
"""
|
||||||
|
# Create request to path that does NOT match /cfg/<key>
|
||||||
|
request = self.factory.get("/")
|
||||||
|
request.COOKIES = {} # no 'key' cookie
|
||||||
|
|
||||||
|
# Patch middleware to capture the 'config' result
|
||||||
|
def get_response(req):
|
||||||
|
req._config = getattr(req, "_config", None)
|
||||||
|
return HttpResponse() # simulate response
|
||||||
|
|
||||||
|
middleware = DetectConfigMiddleware(get_response)
|
||||||
|
response = middleware(request)
|
||||||
|
|
||||||
|
# Validate we entered the `if not config:` branch
|
||||||
|
# You can't test the internal `config` variable directly unless it's exposed
|
||||||
|
self.assertTrue(hasattr(response, "_config") is False)
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,19 +21,19 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
import hashlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from unittest.mock import patch
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
|
|
||||||
class DelTests(SimpleTestCase):
|
class DelTests(SimpleTestCase):
|
||||||
|
|
||||||
def test_del_get_invalid_key_status_code(self):
|
def test_del_get_invalid_key_status_code(self):
|
||||||
"""
|
"""
|
||||||
Test GET requests to invalid key
|
Test GET requests to invalid key
|
||||||
"""
|
"""
|
||||||
response = self.client.get('/del/**invalid-key**')
|
response = self.client.get("/del/**invalid-key**")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_key_lengths(self):
|
def test_key_lengths(self):
|
||||||
@@ -44,27 +43,26 @@ class DelTests(SimpleTestCase):
|
|||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
h = hashlib.sha512()
|
h = hashlib.sha512()
|
||||||
h.update(b'string')
|
h.update(b"string")
|
||||||
key = h.hexdigest()
|
key = h.hexdigest()
|
||||||
|
|
||||||
# Our limit
|
# Our limit
|
||||||
assert len(key) == 128
|
assert len(key) == 128
|
||||||
|
|
||||||
# Add our URL
|
# Add our URL
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
|
||||||
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# remove a key that is too long
|
# remove a key that is too long
|
||||||
response = self.client.post('/del/{}'.format(key + 'x'))
|
response = self.client.post("/del/{}".format(key + "x"))
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
# remove the key
|
# remove the key
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Test again; key is gone
|
# Test again; key is gone
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
@override_settings(APPRISE_CONFIG_LOCK=True)
|
@override_settings(APPRISE_CONFIG_LOCK=True)
|
||||||
@@ -73,10 +71,10 @@ class DelTests(SimpleTestCase):
|
|||||||
Test deleting a configuration by URLs with lock set won't work
|
Test deleting a configuration by URLs with lock set won't work
|
||||||
"""
|
"""
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_delete_with_lock'
|
key = "test_delete_with_lock"
|
||||||
|
|
||||||
# We simply do not have permission to do so
|
# We simply do not have permission to do so
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
def test_del_post(self):
|
def test_del_post(self):
|
||||||
@@ -84,31 +82,30 @@ class DelTests(SimpleTestCase):
|
|||||||
Test DEL POST
|
Test DEL POST
|
||||||
"""
|
"""
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_delete'
|
key = "test_delete"
|
||||||
|
|
||||||
# Invalid Key
|
# Invalid Key
|
||||||
response = self.client.post('/del/**invalid-key**')
|
response = self.client.post("/del/**invalid-key**")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
# A key that just simply isn't present
|
# A key that just simply isn't present
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
# Add our key
|
# Add our key
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
|
||||||
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Test removing key when the OS just can't do it:
|
# Test removing key when the OS just can't do it:
|
||||||
with patch('os.remove', side_effect=OSError):
|
with patch("os.remove", side_effect=OSError):
|
||||||
# We can now remove the key
|
# We can now remove the key
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
|
|
||||||
# We can now remove the key
|
# We can now remove the key
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Key has already been removed
|
# Key has already been removed
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
@@ -26,12 +25,11 @@ from django.test import SimpleTestCase
|
|||||||
|
|
||||||
|
|
||||||
class DetailTests(SimpleTestCase):
|
class DetailTests(SimpleTestCase):
|
||||||
|
|
||||||
def test_post_not_supported(self):
|
def test_post_not_supported(self):
|
||||||
"""
|
"""
|
||||||
Test POST requests
|
Test POST requests
|
||||||
"""
|
"""
|
||||||
response = self.client.post('/details')
|
response = self.client.post("/details")
|
||||||
# 405 as posting is not allowed
|
# 405 as posting is not allowed
|
||||||
assert response.status_code == 405
|
assert response.status_code == 405
|
||||||
|
|
||||||
@@ -41,38 +39,46 @@ class DetailTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Nothing to return
|
# Nothing to return
|
||||||
response = self.client.get('/details')
|
response = self.client.get("/details")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response['Content-Type'].startswith('text/html')
|
assert response["Content-Type"].startswith("text/html")
|
||||||
|
|
||||||
# JSON Response
|
# JSON Response
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
'/details', content_type='application/json',
|
"/details",
|
||||||
**{'HTTP_CONTENT_TYPE': 'application/json'})
|
content_type="application/json",
|
||||||
|
**{"HTTP_CONTENT_TYPE": "application/json"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response['Content-Type'].startswith('application/json')
|
assert response["Content-Type"].startswith("application/json")
|
||||||
|
|
||||||
# JSON Response
|
# JSON Response
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
'/details', content_type='application/json',
|
"/details",
|
||||||
**{'HTTP_ACCEPT': 'application/json'})
|
content_type="application/json",
|
||||||
|
**{"HTTP_ACCEPT": "application/json"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response['Content-Type'].startswith('application/json')
|
assert response["Content-Type"].startswith("application/json")
|
||||||
|
|
||||||
response = self.client.get('/details?all=yes')
|
response = self.client.get("/details?all=yes")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response['Content-Type'].startswith('text/html')
|
assert response["Content-Type"].startswith("text/html")
|
||||||
|
|
||||||
# JSON Response
|
# JSON Response
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
'/details?all=yes', content_type='application/json',
|
"/details?all=yes",
|
||||||
**{'HTTP_CONTENT_TYPE': 'application/json'})
|
content_type="application/json",
|
||||||
|
**{"HTTP_CONTENT_TYPE": "application/json"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response['Content-Type'].startswith('application/json')
|
assert response["Content-Type"].startswith("application/json")
|
||||||
|
|
||||||
# JSON Response
|
# JSON Response
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
'/details?all=yes', content_type='application/json',
|
"/details?all=yes",
|
||||||
**{'HTTP_ACCEPT': 'application/json'})
|
content_type="application/json",
|
||||||
|
**{"HTTP_ACCEPT": "application/json"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response['Content-Type'].startswith('application/json')
|
assert response["Content-Type"].startswith("application/json")
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,17 +21,17 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.test import SimpleTestCase
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
|
||||||
class GetTests(SimpleTestCase):
|
class GetTests(SimpleTestCase):
|
||||||
|
|
||||||
def test_get_invalid_key_status_code(self):
|
def test_get_invalid_key_status_code(self):
|
||||||
"""
|
"""
|
||||||
Test GET requests to invalid key
|
Test GET requests to invalid key
|
||||||
"""
|
"""
|
||||||
response = self.client.get('/get/**invalid-key**')
|
response = self.client.get("/get/**invalid-key**")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_get_config(self):
|
def test_get_config(self):
|
||||||
@@ -41,47 +40,48 @@ class GetTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_get_config_'
|
key = "test_get_config_"
|
||||||
|
|
||||||
# GET returns 405 (not allowed)
|
# GET returns 405 (not allowed)
|
||||||
response = self.client.get('/get/{}'.format(key))
|
response = self.client.get("/get/{}".format(key))
|
||||||
assert response.status_code == 405
|
assert response.status_code == 405
|
||||||
|
|
||||||
# No content saved to the location yet
|
# No content saved to the location yet
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
self.assertEqual(response.status_code, 204)
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
# Add some content
|
# Add some content
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
|
||||||
'/add/{}'.format(key),
|
|
||||||
{'urls': 'mailto://user:pass@yahoo.ca'})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Handle case when we try to retrieve our content but we have no idea
|
# Handle case when we try to retrieve our content but we have no idea
|
||||||
# what the format is in. Essentialy there had to have been disk
|
# what the format is in. Essentialy there had to have been disk
|
||||||
# corruption here or someone meddling with the backend.
|
# corruption here or someone meddling with the backend.
|
||||||
with patch('gzip.open', side_effect=OSError):
|
with patch("gzip.open", side_effect=OSError):
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
|
|
||||||
# Now we should be able to see our content
|
# Now we should be able to see our content
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Add a YAML file
|
# Add a YAML file
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key), {
|
"/add/{}".format(key),
|
||||||
'format': 'yaml',
|
{
|
||||||
'config': """
|
"format": "yaml",
|
||||||
|
"config": """
|
||||||
urls:
|
urls:
|
||||||
- dbus://"""})
|
- dbus://""",
|
||||||
|
},
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Now retrieve our YAML configuration
|
# Now retrieve our YAML configuration
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify that the correct Content-Type is set in the header of the
|
# Verify that the correct Content-Type is set in the header of the
|
||||||
# response
|
# response
|
||||||
assert 'Content-Type' in response
|
assert "Content-Type" in response
|
||||||
assert response['Content-Type'].startswith('text/yaml')
|
assert response["Content-Type"].startswith("text/yaml")
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,20 +21,21 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
import mock
|
|
||||||
from django.test import SimpleTestCase
|
|
||||||
from json import loads
|
from json import loads
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
from ..utils import healthcheck
|
from ..utils import healthcheck
|
||||||
|
|
||||||
|
|
||||||
class HealthCheckTests(SimpleTestCase):
|
class HealthCheckTests(SimpleTestCase):
|
||||||
|
|
||||||
def test_post_not_supported(self):
|
def test_post_not_supported(self):
|
||||||
"""
|
"""
|
||||||
Test POST requests
|
Test POST requests
|
||||||
"""
|
"""
|
||||||
response = self.client.post('/status')
|
response = self.client.post("/status")
|
||||||
# 405 as posting is not allowed
|
# 405 as posting is not allowed
|
||||||
assert response.status_code == 405
|
assert response.status_code == 405
|
||||||
|
|
||||||
@@ -45,129 +45,139 @@ class HealthCheckTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# First Status Check
|
# First Status Check
|
||||||
response = self.client.get('/status')
|
response = self.client.get("/status")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.content, b'OK')
|
self.assertEqual(response.content, b"OK")
|
||||||
assert response['Content-Type'].startswith('text/plain')
|
assert response["Content-Type"].startswith("text/plain")
|
||||||
|
|
||||||
# Second Status Check (Lazy Mode kicks in)
|
# Second Status Check (Lazy Mode kicks in)
|
||||||
response = self.client.get('/status')
|
response = self.client.get("/status")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.content, b'OK')
|
self.assertEqual(response.content, b"OK")
|
||||||
assert response['Content-Type'].startswith('text/plain')
|
assert response["Content-Type"].startswith("text/plain")
|
||||||
|
|
||||||
# JSON Response
|
# JSON Response
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
'/status', content_type='application/json',
|
"/status",
|
||||||
**{'HTTP_CONTENT_TYPE': 'application/json'})
|
content_type="application/json",
|
||||||
|
**{"HTTP_CONTENT_TYPE": "application/json"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
content = loads(response.content)
|
content = loads(response.content)
|
||||||
assert content == {
|
assert content == {
|
||||||
'config_lock': False,
|
"config_lock": False,
|
||||||
'attach_lock': False,
|
"attach_lock": False,
|
||||||
'status': {
|
"status": {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': True,
|
"can_write_config": True,
|
||||||
'can_write_attach': True,
|
"can_write_attach": True,
|
||||||
'details': ['OK']
|
"details": ["OK"],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
assert response['Content-Type'].startswith('application/json')
|
assert response["Content-Type"].startswith("application/json")
|
||||||
|
|
||||||
with override_settings(APPRISE_CONFIG_LOCK=True):
|
with override_settings(APPRISE_CONFIG_LOCK=True):
|
||||||
# Status Check (Form based)
|
# Status Check (Form based)
|
||||||
response = self.client.get('/status')
|
response = self.client.get("/status")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.content, b'OK')
|
self.assertEqual(response.content, b"OK")
|
||||||
assert response['Content-Type'].startswith('text/plain')
|
assert response["Content-Type"].startswith("text/plain")
|
||||||
|
|
||||||
# JSON Response
|
# JSON Response
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
'/status', content_type='application/json',
|
"/status",
|
||||||
**{'HTTP_CONTENT_TYPE': 'application/json'})
|
content_type="application/json",
|
||||||
|
**{"HTTP_CONTENT_TYPE": "application/json"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
content = loads(response.content)
|
content = loads(response.content)
|
||||||
assert content == {
|
assert content == {
|
||||||
'config_lock': True,
|
"config_lock": True,
|
||||||
'attach_lock': False,
|
"attach_lock": False,
|
||||||
'status': {
|
"status": {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': False,
|
"can_write_config": False,
|
||||||
'can_write_attach': True,
|
"can_write_attach": True,
|
||||||
'details': ['OK']
|
"details": ["OK"],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with override_settings(APPRISE_STATEFUL_MODE='disabled'):
|
with override_settings(APPRISE_STATEFUL_MODE="disabled"):
|
||||||
# Status Check (Form based)
|
# Status Check (Form based)
|
||||||
response = self.client.get('/status')
|
response = self.client.get("/status")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.content, b'OK')
|
self.assertEqual(response.content, b"OK")
|
||||||
assert response['Content-Type'].startswith('text/plain')
|
assert response["Content-Type"].startswith("text/plain")
|
||||||
|
|
||||||
# JSON Response
|
# JSON Response
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
'/status', content_type='application/json',
|
"/status",
|
||||||
**{'HTTP_CONTENT_TYPE': 'application/json'})
|
content_type="application/json",
|
||||||
|
**{"HTTP_CONTENT_TYPE": "application/json"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
content = loads(response.content)
|
content = loads(response.content)
|
||||||
assert content == {
|
assert content == {
|
||||||
'config_lock': False,
|
"config_lock": False,
|
||||||
'attach_lock': False,
|
"attach_lock": False,
|
||||||
'status': {
|
"status": {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': False,
|
"can_write_config": False,
|
||||||
'can_write_attach': True,
|
"can_write_attach": True,
|
||||||
'details': ['OK']
|
"details": ["OK"],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with override_settings(APPRISE_ATTACH_SIZE=0):
|
with override_settings(APPRISE_ATTACH_SIZE=0):
|
||||||
# Status Check (Form based)
|
# Status Check (Form based)
|
||||||
response = self.client.get('/status')
|
response = self.client.get("/status")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.content, b'OK')
|
self.assertEqual(response.content, b"OK")
|
||||||
assert response['Content-Type'].startswith('text/plain')
|
assert response["Content-Type"].startswith("text/plain")
|
||||||
|
|
||||||
# JSON Response
|
# JSON Response
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
'/status', content_type='application/json',
|
"/status",
|
||||||
**{'HTTP_CONTENT_TYPE': 'application/json'})
|
content_type="application/json",
|
||||||
|
**{"HTTP_CONTENT_TYPE": "application/json"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
content = loads(response.content)
|
content = loads(response.content)
|
||||||
assert content == {
|
assert content == {
|
||||||
'config_lock': False,
|
"config_lock": False,
|
||||||
'attach_lock': True,
|
"attach_lock": True,
|
||||||
'status': {
|
"status": {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': True,
|
"can_write_config": True,
|
||||||
'can_write_attach': False,
|
"can_write_attach": False,
|
||||||
'details': ['OK']
|
"details": ["OK"],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with override_settings(APPRISE_MAX_ATTACHMENTS=0):
|
with override_settings(APPRISE_MAX_ATTACHMENTS=0):
|
||||||
# Status Check (Form based)
|
# Status Check (Form based)
|
||||||
response = self.client.get('/status')
|
response = self.client.get("/status")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.content, b'OK')
|
self.assertEqual(response.content, b"OK")
|
||||||
assert response['Content-Type'].startswith('text/plain')
|
assert response["Content-Type"].startswith("text/plain")
|
||||||
|
|
||||||
# JSON Response
|
# JSON Response
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
'/status', content_type='application/json',
|
"/status",
|
||||||
**{'HTTP_CONTENT_TYPE': 'application/json'})
|
content_type="application/json",
|
||||||
|
**{"HTTP_CONTENT_TYPE": "application/json"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
content = loads(response.content)
|
content = loads(response.content)
|
||||||
assert content == {
|
assert content == {
|
||||||
'config_lock': False,
|
"config_lock": False,
|
||||||
'attach_lock': False,
|
"attach_lock": False,
|
||||||
'status': {
|
"status": {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': True,
|
"can_write_config": True,
|
||||||
'can_write_attach': True,
|
"can_write_attach": True,
|
||||||
'details': ['OK']
|
"details": ["OK"],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_healthcheck_library(self):
|
def test_healthcheck_library(self):
|
||||||
@@ -177,115 +187,129 @@ class HealthCheckTests(SimpleTestCase):
|
|||||||
|
|
||||||
result = healthcheck(lazy=True)
|
result = healthcheck(lazy=True)
|
||||||
assert result == {
|
assert result == {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': True,
|
"can_write_config": True,
|
||||||
'can_write_attach': True,
|
"can_write_attach": True,
|
||||||
'details': ['OK']
|
"details": ["OK"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# A Double lazy check
|
# A Double lazy check
|
||||||
result = healthcheck(lazy=True)
|
result = healthcheck(lazy=True)
|
||||||
assert result == {
|
assert result == {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': True,
|
"can_write_config": True,
|
||||||
'can_write_attach': True,
|
"can_write_attach": True,
|
||||||
'details': ['OK']
|
"details": ["OK"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Force a lazy check where we can't acquire the modify time
|
# Force a lazy check where we can't acquire the modify time
|
||||||
with mock.patch('os.path.getmtime') as mock_getmtime:
|
with mock.patch("os.path.getmtime") as mock_getmtime:
|
||||||
mock_getmtime.side_effect = FileNotFoundError()
|
mock_getmtime.side_effect = FileNotFoundError()
|
||||||
result = healthcheck(lazy=True)
|
result = healthcheck(lazy=True)
|
||||||
# We still succeed; we just don't leverage our lazy check
|
# We still succeed; we just don't leverage our lazy check
|
||||||
# which prevents addition (unnessisary) writes
|
# which prevents addition (unnessisary) writes
|
||||||
assert result == {
|
assert result == {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': True,
|
"can_write_config": True,
|
||||||
'can_write_attach': True,
|
"can_write_attach": True,
|
||||||
'details': ['OK'],
|
"details": ["OK"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Force a lazy check where we can't acquire the modify time
|
# Force a lazy check where we can't acquire the modify time
|
||||||
with mock.patch('os.path.getmtime') as mock_getmtime:
|
with mock.patch("os.path.getmtime") as mock_getmtime:
|
||||||
mock_getmtime.side_effect = OSError()
|
mock_getmtime.side_effect = OSError()
|
||||||
result = healthcheck(lazy=True)
|
result = healthcheck(lazy=True)
|
||||||
# We still succeed; we just don't leverage our lazy check
|
# We still succeed; we just don't leverage our lazy check
|
||||||
# which prevents addition (unnessisary) writes
|
# which prevents addition (unnessisary) writes
|
||||||
assert result == {
|
assert result == {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': False,
|
"can_write_config": False,
|
||||||
'can_write_attach': False,
|
"can_write_attach": False,
|
||||||
'details': [
|
"details": [
|
||||||
'CONFIG_PERMISSION_ISSUE',
|
"CONFIG_PERMISSION_ISSUE",
|
||||||
'ATTACH_PERMISSION_ISSUE',
|
"ATTACH_PERMISSION_ISSUE",
|
||||||
]}
|
],
|
||||||
|
}
|
||||||
|
|
||||||
# Force a non-lazy check
|
# Force a non-lazy check
|
||||||
with mock.patch('os.makedirs') as mock_makedirs:
|
with mock.patch("os.makedirs") as mock_makedirs:
|
||||||
mock_makedirs.side_effect = OSError()
|
mock_makedirs.side_effect = OSError()
|
||||||
result = healthcheck(lazy=False)
|
result = healthcheck(lazy=False)
|
||||||
assert result == {
|
assert result == {
|
||||||
'persistent_storage': False,
|
"persistent_storage": False,
|
||||||
'can_write_config': False,
|
"can_write_config": False,
|
||||||
'can_write_attach': False,
|
"can_write_attach": False,
|
||||||
'details': [
|
"details": [
|
||||||
'CONFIG_PERMISSION_ISSUE',
|
"CONFIG_PERMISSION_ISSUE",
|
||||||
'ATTACH_PERMISSION_ISSUE',
|
"ATTACH_PERMISSION_ISSUE",
|
||||||
'STORE_PERMISSION_ISSUE',
|
"STORE_PERMISSION_ISSUE",
|
||||||
]}
|
],
|
||||||
|
}
|
||||||
|
|
||||||
with mock.patch('os.path.getmtime') as mock_getmtime:
|
with mock.patch("os.path.getmtime") as mock_getmtime, \
|
||||||
with mock.patch('os.fdopen', side_effect=OSError()):
|
mock.patch("os.fdopen", side_effect=OSError()):
|
||||||
mock_getmtime.side_effect = OSError()
|
mock_getmtime.side_effect = OSError()
|
||||||
mock_makedirs.side_effect = None
|
mock_makedirs.side_effect = None
|
||||||
result = healthcheck(lazy=False)
|
|
||||||
assert result == {
|
|
||||||
'persistent_storage': True,
|
|
||||||
'can_write_config': False,
|
|
||||||
'can_write_attach': False,
|
|
||||||
'details': [
|
|
||||||
'CONFIG_PERMISSION_ISSUE',
|
|
||||||
'ATTACH_PERMISSION_ISSUE',
|
|
||||||
]}
|
|
||||||
|
|
||||||
with mock.patch('apprise.PersistentStore.flush', return_value=False):
|
|
||||||
result = healthcheck(lazy=False)
|
result = healthcheck(lazy=False)
|
||||||
assert result == {
|
assert result == {
|
||||||
'persistent_storage': False,
|
"persistent_storage": True,
|
||||||
'can_write_config': True,
|
"can_write_config": False,
|
||||||
'can_write_attach': True,
|
"can_write_attach": False,
|
||||||
'details': [
|
"details": [
|
||||||
'STORE_PERMISSION_ISSUE',
|
"CONFIG_PERMISSION_ISSUE",
|
||||||
]}
|
"ATTACH_PERMISSION_ISSUE",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
with mock.patch("apprise.PersistentStore.flush", return_value=False):
|
||||||
|
result = healthcheck(lazy=False)
|
||||||
|
assert result == {
|
||||||
|
"persistent_storage": False,
|
||||||
|
"can_write_config": True,
|
||||||
|
"can_write_attach": True,
|
||||||
|
"details": [
|
||||||
|
"STORE_PERMISSION_ISSUE",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
# Test a case where we simply do not define a persistent store path
|
# Test a case where we simply do not define a persistent store path
|
||||||
# health checks will always disable persistent storage
|
# health checks will always disable persistent storage
|
||||||
with override_settings(APPRISE_STORAGE_DIR=""):
|
with override_settings(APPRISE_STORAGE_DIR=""), \
|
||||||
with mock.patch('apprise.PersistentStore.flush', return_value=False):
|
mock.patch("apprise.PersistentStore.flush", return_value=False):
|
||||||
result = healthcheck(lazy=False)
|
result = healthcheck(lazy=False)
|
||||||
assert result == {
|
assert result == {
|
||||||
'persistent_storage': False,
|
"persistent_storage": False,
|
||||||
'can_write_config': True,
|
"can_write_config": True,
|
||||||
'can_write_attach': True,
|
"can_write_attach": True,
|
||||||
'details': ['OK']}
|
"details": ["OK"],
|
||||||
|
}
|
||||||
|
|
||||||
mock_makedirs.side_effect = (OSError(), OSError(), None, None, None, None)
|
mock_makedirs.side_effect = (
|
||||||
|
OSError(),
|
||||||
|
OSError(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
result = healthcheck(lazy=False)
|
result = healthcheck(lazy=False)
|
||||||
assert result == {
|
assert result == {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': False,
|
"can_write_config": False,
|
||||||
'can_write_attach': False,
|
"can_write_attach": False,
|
||||||
'details': [
|
"details": [
|
||||||
'CONFIG_PERMISSION_ISSUE',
|
"CONFIG_PERMISSION_ISSUE",
|
||||||
'ATTACH_PERMISSION_ISSUE',
|
"ATTACH_PERMISSION_ISSUE",
|
||||||
]}
|
],
|
||||||
|
}
|
||||||
|
|
||||||
mock_makedirs.side_effect = (OSError(), None, None, None, None)
|
mock_makedirs.side_effect = (OSError(), None, None, None, None)
|
||||||
result = healthcheck(lazy=False)
|
result = healthcheck(lazy=False)
|
||||||
assert result == {
|
assert result == {
|
||||||
'persistent_storage': True,
|
"persistent_storage": True,
|
||||||
'can_write_config': False,
|
"can_write_config": False,
|
||||||
'can_write_attach': True,
|
"can_write_attach": True,
|
||||||
'details': [
|
"details": [
|
||||||
'CONFIG_PERMISSION_ISSUE',
|
"CONFIG_PERMISSION_ISSUE",
|
||||||
]}
|
],
|
||||||
|
}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,25 +21,25 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
class JsonUrlsTests(SimpleTestCase):
|
class JsonUrlsTests(SimpleTestCase):
|
||||||
|
|
||||||
def test_get_invalid_key_status_code(self):
|
def test_get_invalid_key_status_code(self):
|
||||||
"""
|
"""
|
||||||
Test GET requests to invalid key
|
Test GET requests to invalid key
|
||||||
"""
|
"""
|
||||||
response = self.client.get('/get/**invalid-key**')
|
response = self.client.get("/get/**invalid-key**")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
def test_post_not_supported(self):
|
def test_post_not_supported(self):
|
||||||
"""
|
"""
|
||||||
Test POST requests with key
|
Test POST requests with key
|
||||||
"""
|
"""
|
||||||
response = self.client.post('/json/urls/test')
|
response = self.client.post("/json/urls/test")
|
||||||
# 405 as posting is not allowed
|
# 405 as posting is not allowed
|
||||||
assert response.status_code == 405
|
assert response.status_code == 405
|
||||||
|
|
||||||
@@ -50,111 +49,108 @@ class JsonUrlsTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_json_urls_config'
|
key = "test_json_urls_config"
|
||||||
|
|
||||||
# Nothing to return
|
# Nothing to return
|
||||||
response = self.client.get('/json/urls/{}'.format(key))
|
response = self.client.get("/json/urls/{}".format(key))
|
||||||
self.assertEqual(response.status_code, 204)
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
# Add some content
|
# Add some content
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
|
||||||
'/add/{}'.format(key),
|
|
||||||
{'urls': 'mailto://user:pass@yahoo.ca'})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Handle case when we try to retrieve our content but we have no idea
|
# Handle case when we try to retrieve our content but we have no idea
|
||||||
# what the format is in. Essentialy there had to have been disk
|
# what the format is in. Essentialy there had to have been disk
|
||||||
# corruption here or someone meddling with the backend.
|
# corruption here or someone meddling with the backend.
|
||||||
with patch('gzip.open', side_effect=OSError):
|
with patch("gzip.open", side_effect=OSError):
|
||||||
response = self.client.get('/json/urls/{}'.format(key))
|
response = self.client.get("/json/urls/{}".format(key))
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
assert response['Content-Type'].startswith('application/json')
|
assert response["Content-Type"].startswith("application/json")
|
||||||
assert 'tags' in response.json()
|
assert "tags" in response.json()
|
||||||
assert 'urls' in response.json()
|
assert "urls" in response.json()
|
||||||
|
|
||||||
# has error directive
|
# has error directive
|
||||||
assert 'error' in response.json()
|
assert "error" in response.json()
|
||||||
|
|
||||||
# entries exist by are empty
|
# entries exist by are empty
|
||||||
assert len(response.json()['tags']) == 0
|
assert len(response.json()["tags"]) == 0
|
||||||
assert len(response.json()['urls']) == 0
|
assert len(response.json()["urls"]) == 0
|
||||||
|
|
||||||
# Now we should be able to see our content
|
# Now we should be able to see our content
|
||||||
response = self.client.get('/json/urls/{}'.format(key))
|
response = self.client.get("/json/urls/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response['Content-Type'].startswith('application/json')
|
assert response["Content-Type"].startswith("application/json")
|
||||||
assert 'tags' in response.json()
|
assert "tags" in response.json()
|
||||||
assert 'urls' in response.json()
|
assert "urls" in response.json()
|
||||||
|
|
||||||
# No errors occurred, therefore no error entry
|
# No errors occurred, therefore no error entry
|
||||||
assert 'error' not in response.json()
|
assert "error" not in response.json()
|
||||||
|
|
||||||
# No tags (but can be assumed "all") is always present
|
# No tags (but can be assumed "all") is always present
|
||||||
assert len(response.json()['tags']) == 0
|
assert len(response.json()["tags"]) == 0
|
||||||
|
|
||||||
# Same request as above but we set the privacy flag
|
# Same request as above but we set the privacy flag
|
||||||
response = self.client.get('/json/urls/{}?privacy=1'.format(key))
|
response = self.client.get("/json/urls/{}?privacy=1".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response['Content-Type'].startswith('application/json')
|
assert response["Content-Type"].startswith("application/json")
|
||||||
assert 'tags' in response.json()
|
assert "tags" in response.json()
|
||||||
assert 'urls' in response.json()
|
assert "urls" in response.json()
|
||||||
|
|
||||||
# No errors occurred, therefore no error entry
|
# No errors occurred, therefore no error entry
|
||||||
assert 'error' not in response.json()
|
assert "error" not in response.json()
|
||||||
|
|
||||||
# No tags (but can be assumed "all") is always present
|
# No tags (but can be assumed "all") is always present
|
||||||
assert len(response.json()['tags']) == 0
|
assert len(response.json()["tags"]) == 0
|
||||||
|
|
||||||
# One URL loaded
|
# One URL loaded
|
||||||
assert len(response.json()['urls']) == 1
|
assert len(response.json()["urls"]) == 1
|
||||||
assert 'url' in response.json()['urls'][0]
|
assert "url" in response.json()["urls"][0]
|
||||||
assert 'tags' in response.json()['urls'][0]
|
assert "tags" in response.json()["urls"][0]
|
||||||
assert len(response.json()['urls'][0]['tags']) == 0
|
assert len(response.json()["urls"][0]["tags"]) == 0
|
||||||
|
|
||||||
# We can see that th URLs are not the same when the privacy flag is set
|
# We can see that th URLs are not the same when the privacy flag is set
|
||||||
without_privacy = \
|
without_privacy = self.client.get("/json/urls/{}?privacy=1".format(key))
|
||||||
self.client.get('/json/urls/{}?privacy=1'.format(key))
|
with_privacy = self.client.get("/json/urls/{}".format(key))
|
||||||
with_privacy = self.client.get('/json/urls/{}'.format(key))
|
assert with_privacy.json()["urls"][0] != without_privacy.json()["urls"][0]
|
||||||
assert with_privacy.json()['urls'][0] != \
|
|
||||||
without_privacy.json()['urls'][0]
|
|
||||||
|
|
||||||
with override_settings(APPRISE_CONFIG_LOCK=True):
|
with override_settings(APPRISE_CONFIG_LOCK=True):
|
||||||
# When our configuration lock is set, our result set enforces the
|
# When our configuration lock is set, our result set enforces the
|
||||||
# privacy flag even if it was otherwise set:
|
# privacy flag even if it was otherwise set:
|
||||||
with_privacy = \
|
with_privacy = self.client.get("/json/urls/{}?privacy=1".format(key))
|
||||||
self.client.get('/json/urls/{}?privacy=1'.format(key))
|
|
||||||
|
|
||||||
# But now they're the same under this new condition
|
# But now they're the same under this new condition
|
||||||
assert with_privacy.json()['urls'][0] == \
|
assert with_privacy.json()["urls"][0] == without_privacy.json()["urls"][0]
|
||||||
without_privacy.json()['urls'][0]
|
|
||||||
|
|
||||||
# Add a YAML file
|
# Add a YAML file
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/add/{}'.format(key), {
|
"/add/{}".format(key),
|
||||||
'format': 'yaml',
|
{
|
||||||
'config': """
|
"format": "yaml",
|
||||||
|
"config": """
|
||||||
urls:
|
urls:
|
||||||
- dbus://:
|
- dbus://:
|
||||||
- tag: tag1, tag2"""})
|
- tag: tag1, tag2""",
|
||||||
|
},
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Now retrieve our JSON resonse
|
# Now retrieve our JSON resonse
|
||||||
response = self.client.get('/json/urls/{}'.format(key))
|
response = self.client.get("/json/urls/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# No errors occured, therefore no error entry
|
# No errors occured, therefore no error entry
|
||||||
assert 'error' not in response.json()
|
assert "error" not in response.json()
|
||||||
|
|
||||||
# No tags (but can be assumed "all") is always present
|
# No tags (but can be assumed "all") is always present
|
||||||
assert len(response.json()['tags']) == 2
|
assert len(response.json()["tags"]) == 2
|
||||||
|
|
||||||
# One URL loaded
|
# One URL loaded
|
||||||
assert len(response.json()['urls']) == 1
|
assert len(response.json()["urls"]) == 1
|
||||||
assert 'url' in response.json()['urls'][0]
|
assert "url" in response.json()["urls"][0]
|
||||||
assert 'tags' in response.json()['urls'][0]
|
assert "tags" in response.json()["urls"][0]
|
||||||
assert len(response.json()['urls'][0]['tags']) == 2
|
assert len(response.json()["urls"][0]["tags"]) == 2
|
||||||
|
|
||||||
# Verify that the correct Content-Type is set in the header of the
|
# Verify that the correct Content-Type is set in the header of the
|
||||||
# response
|
# response
|
||||||
assert 'Content-Type' in response
|
assert "Content-Type" in response
|
||||||
assert response['Content-Type'].startswith('application/json')
|
assert response["Content-Type"].startswith("application/json")
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,8 +21,7 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase, override_settings
|
||||||
from django.test import override_settings
|
|
||||||
|
|
||||||
|
|
||||||
class ManagerPageTests(SimpleTestCase):
|
class ManagerPageTests(SimpleTestCase):
|
||||||
@@ -36,34 +34,34 @@ class ManagerPageTests(SimpleTestCase):
|
|||||||
General testing of management page
|
General testing of management page
|
||||||
"""
|
"""
|
||||||
# No permission to get keys
|
# No permission to get keys
|
||||||
response = self.client.get('/cfg/')
|
response = self.client.get("/cfg/")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='hash'):
|
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="hash"):
|
||||||
response = self.client.get('/cfg/')
|
response = self.client.get("/cfg/")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE='simple'):
|
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE="simple"):
|
||||||
response = self.client.get('/cfg/')
|
response = self.client.get("/cfg/")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE='disabled'):
|
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE="disabled"):
|
||||||
response = self.client.get('/cfg/')
|
response = self.client.get("/cfg/")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='disabled'):
|
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="disabled"):
|
||||||
response = self.client.get('/cfg/')
|
response = self.client.get("/cfg/")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
# But only when the setting is enabled
|
# But only when the setting is enabled
|
||||||
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='simple'):
|
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="simple"):
|
||||||
response = self.client.get('/cfg/')
|
response = self.client.get("/cfg/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# An invalid key was specified
|
# An invalid key was specified
|
||||||
response = self.client.get('/cfg/**invalid-key**')
|
response = self.client.get("/cfg/**invalid-key**")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
# An invalid key was specified
|
# An invalid key was specified
|
||||||
response = self.client.get('/cfg/valid-key')
|
response = self.client.get("/cfg/valid-key")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
from ..payload_mapper import remap_fields
|
from ..payload_mapper import remap_fields
|
||||||
|
|
||||||
|
|
||||||
@@ -41,9 +41,9 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
#
|
#
|
||||||
rules = {}
|
rules = {}
|
||||||
payload = {
|
payload = {
|
||||||
'format': 'markdown',
|
"format": "markdown",
|
||||||
'title': 'title',
|
"title": "title",
|
||||||
'body': '# body',
|
"body": "# body",
|
||||||
}
|
}
|
||||||
payload_orig = payload.copy()
|
payload_orig = payload.copy()
|
||||||
|
|
||||||
@@ -58,32 +58,29 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
#
|
#
|
||||||
rules = {
|
rules = {
|
||||||
# map 'as' to 'format'
|
# map 'as' to 'format'
|
||||||
'as': 'format',
|
"as": "format",
|
||||||
# map 'subject' to 'title'
|
# map 'subject' to 'title'
|
||||||
'subject': 'title',
|
"subject": "title",
|
||||||
# map 'content' to 'body'
|
# map 'content' to 'body'
|
||||||
'content': 'body',
|
"content": "body",
|
||||||
# 'missing' is an invalid entry so this will be skipped
|
# 'missing' is an invalid entry so this will be skipped
|
||||||
'unknown': 'missing',
|
"unknown": "missing",
|
||||||
|
|
||||||
# Empty field
|
# Empty field
|
||||||
'attachment': '',
|
"attachment": "",
|
||||||
|
|
||||||
# Garbage is an field that can be removed since it doesn't
|
# Garbage is an field that can be removed since it doesn't
|
||||||
# conflict with the form
|
# conflict with the form
|
||||||
'garbage': '',
|
"garbage": "",
|
||||||
|
|
||||||
# Tag
|
# Tag
|
||||||
'tag': 'test',
|
"tag": "test",
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
'as': 'markdown',
|
"as": "markdown",
|
||||||
'subject': 'title',
|
"subject": "title",
|
||||||
'content': '# body',
|
"content": "# body",
|
||||||
'tag': '',
|
"tag": "",
|
||||||
'unknown': 'hmm',
|
"unknown": "hmm",
|
||||||
'attachment': '',
|
"attachment": "",
|
||||||
'garbage': '',
|
"garbage": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Map our fields
|
# Map our fields
|
||||||
@@ -91,11 +88,11 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
|
|
||||||
# Our field mappings have taken place
|
# Our field mappings have taken place
|
||||||
assert payload == {
|
assert payload == {
|
||||||
'tag': 'test',
|
"tag": "test",
|
||||||
'unknown': 'missing',
|
"unknown": "missing",
|
||||||
'format': 'markdown',
|
"format": "markdown",
|
||||||
'title': 'title',
|
"title": "title",
|
||||||
'body': '# body',
|
"body": "# body",
|
||||||
}
|
}
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -104,18 +101,18 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
rules = {
|
rules = {
|
||||||
#
|
#
|
||||||
# map 'content' to 'body'
|
# map 'content' to 'body'
|
||||||
'content': 'body',
|
"content": "body",
|
||||||
# a double mapping to body will trigger an error
|
# a double mapping to body will trigger an error
|
||||||
'message': 'body',
|
"message": "body",
|
||||||
# Swapping fields
|
# Swapping fields
|
||||||
'body': 'another set of data',
|
"body": "another set of data",
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
'as': 'markdown',
|
"as": "markdown",
|
||||||
'subject': 'title',
|
"subject": "title",
|
||||||
'content': '# content body',
|
"content": "# content body",
|
||||||
'message': '# message body',
|
"message": "# message body",
|
||||||
'body': 'another set of data',
|
"body": "another set of data",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Map our fields
|
# Map our fields
|
||||||
@@ -123,9 +120,9 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
|
|
||||||
# Our information gets swapped
|
# Our information gets swapped
|
||||||
assert payload == {
|
assert payload == {
|
||||||
'as': 'markdown',
|
"as": "markdown",
|
||||||
'subject': 'title',
|
"subject": "title",
|
||||||
'body': 'another set of data',
|
"body": "another set of data",
|
||||||
}
|
}
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -134,12 +131,12 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
rules = {
|
rules = {
|
||||||
#
|
#
|
||||||
# map 'content' to 'body'
|
# map 'content' to 'body'
|
||||||
'title': 'body',
|
"title": "body",
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
'format': 'markdown',
|
"format": "markdown",
|
||||||
'title': 'body',
|
"title": "body",
|
||||||
'body': '# title',
|
"body": "# title",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Map our fields
|
# Map our fields
|
||||||
@@ -147,9 +144,9 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
|
|
||||||
# Our information gets swapped
|
# Our information gets swapped
|
||||||
assert payload == {
|
assert payload == {
|
||||||
'format': 'markdown',
|
"format": "markdown",
|
||||||
'title': '# title',
|
"title": "# title",
|
||||||
'body': 'body',
|
"body": "body",
|
||||||
}
|
}
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -158,11 +155,11 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
rules = {
|
rules = {
|
||||||
#
|
#
|
||||||
# map 'content' to 'body'
|
# map 'content' to 'body'
|
||||||
'title': 'body',
|
"title": "body",
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
'format': 'markdown',
|
"format": "markdown",
|
||||||
'title': 'body',
|
"title": "body",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Map our fields
|
# Map our fields
|
||||||
@@ -170,8 +167,8 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
|
|
||||||
# Our information gets swapped
|
# Our information gets swapped
|
||||||
assert payload == {
|
assert payload == {
|
||||||
'format': 'markdown',
|
"format": "markdown",
|
||||||
'body': 'body',
|
"body": "body",
|
||||||
}
|
}
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -180,12 +177,12 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
rules = {
|
rules = {
|
||||||
#
|
#
|
||||||
# map 'content' to 'body'
|
# map 'content' to 'body'
|
||||||
'content': 'body',
|
"content": "body",
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
'format': 'markdown',
|
"format": "markdown",
|
||||||
'content': 'the message',
|
"content": "the message",
|
||||||
'body': 'to-be-replaced',
|
"body": "to-be-replaced",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Map our fields
|
# Map our fields
|
||||||
@@ -193,26 +190,26 @@ class NotifyPayloadMapper(SimpleTestCase):
|
|||||||
|
|
||||||
# Our information gets swapped
|
# Our information gets swapped
|
||||||
assert payload == {
|
assert payload == {
|
||||||
'format': 'markdown',
|
"format": "markdown",
|
||||||
'body': 'the message',
|
"body": "the message",
|
||||||
}
|
}
|
||||||
|
|
||||||
#
|
#
|
||||||
# mapping of fields don't align - test 6
|
# mapping of fields don't align - test 6
|
||||||
#
|
#
|
||||||
rules = {
|
rules = {
|
||||||
'payload': 'body',
|
"payload": "body",
|
||||||
'fmt': 'format',
|
"fmt": "format",
|
||||||
'extra': 'tag',
|
"extra": "tag",
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
'format': 'markdown',
|
"format": "markdown",
|
||||||
'type': 'info',
|
"type": "info",
|
||||||
'title': '',
|
"title": "",
|
||||||
'body': '## test notifiction',
|
"body": "## test notifiction",
|
||||||
'attachment': None,
|
"attachment": None,
|
||||||
'tag': 'general',
|
"tag": "general",
|
||||||
'tags': '',
|
"tags": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Make a copy of our original payload
|
# Make a copy of our original payload
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,17 +21,20 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.test import SimpleTestCase
|
import inspect
|
||||||
from django.test.utils import override_settings
|
|
||||||
from unittest.mock import patch, Mock
|
|
||||||
from ..forms import NotifyForm
|
|
||||||
from ..utils import ConfigCache
|
|
||||||
from json import dumps
|
from json import dumps
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import apprise
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
import requests
|
import requests
|
||||||
import inspect
|
|
||||||
|
import apprise
|
||||||
|
|
||||||
|
from ..forms import NotifyForm
|
||||||
|
from ..utils import ConfigCache
|
||||||
|
|
||||||
# Grant access to our Notification Manager Singleton
|
# Grant access to our Notification Manager Singleton
|
||||||
N_MGR = apprise.manager_plugins.NotificationManager()
|
N_MGR = apprise.manager_plugins.NotificationManager()
|
||||||
@@ -49,15 +51,15 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
Test the retrieval of configuration when the lock is set
|
Test the retrieval of configuration when the lock is set
|
||||||
"""
|
"""
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_stateful_with_lock'
|
key = "test_stateful_with_lock"
|
||||||
|
|
||||||
# It doesn't matter if there is or isn't any configuration; when this
|
# It doesn't matter if there is or isn't any configuration; when this
|
||||||
# flag is set. All that overhead is skipped and we're denied access
|
# flag is set. All that overhead is skipped and we're denied access
|
||||||
# right off the bat
|
# right off the bat
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
@patch('requests.post')
|
@patch("requests.post")
|
||||||
def test_stateful_configuration_io(self, mock_post):
|
def test_stateful_configuration_io(self, mock_post):
|
||||||
"""
|
"""
|
||||||
Test the writing, removal, writing and removal of configuration to
|
Test the writing, removal, writing and removal of configuration to
|
||||||
@@ -65,50 +67,48 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_stateful_01'
|
key = "test_stateful_01"
|
||||||
|
|
||||||
request = Mock()
|
request = Mock()
|
||||||
request.content = b'ok'
|
request.content = b"ok"
|
||||||
request.status_code = requests.codes.ok
|
request.status_code = requests.codes.ok
|
||||||
mock_post.return_value = request
|
mock_post.return_value = request
|
||||||
|
|
||||||
# Monkey Patch
|
# Monkey Patch
|
||||||
N_MGR['mailto'].enabled = True
|
N_MGR["mailto"].enabled = True
|
||||||
|
|
||||||
# Preare our list of URLs we want to save
|
# Preare our list of URLs we want to save
|
||||||
urls = [
|
urls = [
|
||||||
'pushbullet=pbul://tokendetails',
|
"pushbullet=pbul://tokendetails",
|
||||||
'general,json=json://hostname',
|
"general,json=json://hostname",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Monkey Patch
|
# Monkey Patch
|
||||||
N_MGR['pbul'].enabled = True
|
N_MGR["pbul"].enabled = True
|
||||||
N_MGR['json'].enabled = True
|
N_MGR["json"].enabled = True
|
||||||
|
|
||||||
# For 10 iterations, repeat these tests to verify that don't change
|
# For 10 iterations, repeat these tests to verify that don't change
|
||||||
# and our saved content is not different on subsequent calls.
|
# and our saved content is not different on subsequent calls.
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
# No content saved to the location yet
|
# No content saved to the location yet
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
# Add our content
|
# Add our content
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"config": "\r\n".join(urls)})
|
||||||
'/add/{}'.format(key),
|
|
||||||
{'config': '\r\n'.join(urls)})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Now we should be able to see our content
|
# Now we should be able to see our content
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
entries = re.split(r'[\r*\n]+', response.content.decode('utf-8'))
|
entries = re.split(r"[\r*\n]+", response.content.decode("utf-8"))
|
||||||
assert len(entries) == 2
|
assert len(entries) == 2
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'tag': 'general',
|
"tag": "general",
|
||||||
}
|
}
|
||||||
|
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
@@ -116,53 +116,54 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# We sent the notification successfully
|
# We sent the notification successfully
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_post.call_count == 1
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'payload': '## test notification',
|
"payload": "## test notification",
|
||||||
'fmt': apprise.NotifyFormat.MARKDOWN,
|
"fmt": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'extra': 'general',
|
"extra": "general",
|
||||||
}
|
}
|
||||||
|
|
||||||
# We sent the notification successfully (use our rule mapping)
|
# We sent the notification successfully (use our rule mapping)
|
||||||
# FORM
|
# FORM
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/notify/{key}/?:payload=body&:fmt=format&:extra=tag',
|
f"/notify/{key}/?:payload=body&:fmt=format&:extra=tag",
|
||||||
form_data)
|
form_data,
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_post.call_count == 1
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'payload': '## test notification',
|
"payload": "## test notification",
|
||||||
'fmt': apprise.NotifyFormat.MARKDOWN,
|
"fmt": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'extra': 'general',
|
"extra": "general",
|
||||||
}
|
}
|
||||||
|
|
||||||
# We sent the notification successfully (use our rule mapping)
|
# We sent the notification successfully (use our rule mapping)
|
||||||
# JSON
|
# JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f'/notify/{key}/?:payload=body&:fmt=format&:extra=tag',
|
f"/notify/{key}/?:payload=body&:fmt=format&:extra=tag",
|
||||||
dumps(form_data),
|
dumps(form_data),
|
||||||
content_type="application/json")
|
content_type="application/json",
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_post.call_count == 1
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'tag': 'no-on-with-this-tag',
|
"tag": "no-on-with-this-tag",
|
||||||
}
|
}
|
||||||
|
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
@@ -170,22 +171,21 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# No one to notify
|
# No one to notify
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
assert response.status_code == 424
|
assert response.status_code == 424
|
||||||
assert mock_post.call_count == 0
|
assert mock_post.call_count == 0
|
||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
# Now empty our data
|
# Now empty our data
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# A second call; but there is nothing to remove
|
# A second call; but there is nothing to remove
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
# Reset our count
|
# Reset our count
|
||||||
@@ -193,28 +193,26 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Now we do a similar approach as the above except we remove the
|
# Now we do a similar approach as the above except we remove the
|
||||||
# configuration from under the application
|
# configuration from under the application
|
||||||
key = 'test_stateful_02'
|
key = "test_stateful_02"
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
# No content saved to the location yet
|
# No content saved to the location yet
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
# Add our content
|
# Add our content
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"config": "\r\n".join(urls)})
|
||||||
'/add/{}'.format(key),
|
|
||||||
{'config': '\r\n'.join(urls)})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Now we should be able to see our content
|
# Now we should be able to see our content
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
entries = re.split(r'[\r*\n]+', response.content.decode('utf-8'))
|
entries = re.split(r"[\r*\n]+", response.content.decode("utf-8"))
|
||||||
assert len(entries) == 2
|
assert len(entries) == 2
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
@@ -222,11 +220,10 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# No one to notify (no tag specified)
|
# No one to notify (no tag specified)
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
assert response.status_code == 424
|
assert response.status_code == 424
|
||||||
assert mock_post.call_count == 0
|
assert mock_post.call_count == 0
|
||||||
|
|
||||||
@@ -237,9 +234,9 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
# Test tagging now
|
# Test tagging now
|
||||||
#
|
#
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'tag': 'general+json',
|
"tag": "general+json",
|
||||||
}
|
}
|
||||||
|
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
@@ -247,10 +244,9 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
# + (plus) not supported at this time
|
# + (plus) not supported at this time
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert mock_post.call_count == 0
|
assert mock_post.call_count == 0
|
||||||
@@ -259,10 +255,10 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
# Plus with space inbetween
|
# Plus with space inbetween
|
||||||
'tag': 'general + json',
|
"tag": "general + json",
|
||||||
}
|
}
|
||||||
|
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
@@ -270,10 +266,9 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
# + (plus) not supported at this time
|
# + (plus) not supported at this time
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert mock_post.call_count == 0
|
assert mock_post.call_count == 0
|
||||||
@@ -281,10 +276,10 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
# Space (AND)
|
# Space (AND)
|
||||||
'tag': 'general json',
|
"tag": "general json",
|
||||||
}
|
}
|
||||||
|
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
@@ -292,20 +287,19 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_post.call_count == 1
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
# Comma (OR)
|
# Comma (OR)
|
||||||
'tag': 'pushbullet, json',
|
"tag": "pushbullet, json",
|
||||||
}
|
}
|
||||||
|
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
@@ -313,10 +307,9 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# 2 endpoints hit
|
# 2 endpoints hit
|
||||||
@@ -325,7 +318,7 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
# Now remove the file directly (as though one
|
# Now remove the file directly (as though one
|
||||||
# removed the configuration directory)
|
# removed the configuration directory)
|
||||||
result = ConfigCache.path(key)
|
result = ConfigCache.path(key)
|
||||||
entry = os.path.join(result[0], '{}.text'.format(result[1]))
|
entry = os.path.join(result[0], "{}.text".format(result[1]))
|
||||||
assert os.path.isfile(entry)
|
assert os.path.isfile(entry)
|
||||||
# The removal
|
# The removal
|
||||||
os.unlink(entry)
|
os.unlink(entry)
|
||||||
@@ -333,30 +326,31 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
assert not os.path.isfile(entry)
|
assert not os.path.isfile(entry)
|
||||||
|
|
||||||
# Call /del/ but now there is nothing to remove
|
# Call /del/ but now there is nothing to remove
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
# Reset our count
|
# Reset our count
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
@patch('requests.post')
|
@patch("requests.post")
|
||||||
def test_stateful_group_dict_notify(self, mock_post):
|
def test_stateful_group_dict_notify(self, mock_post):
|
||||||
"""
|
"""
|
||||||
Test the handling of a group defined as a dictionary
|
Test the handling of a group defined as a dictionary
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_stateful_group_notify_dict'
|
key = "test_stateful_group_notify_dict"
|
||||||
|
|
||||||
request = Mock()
|
request = Mock()
|
||||||
request.content = b'ok'
|
request.content = b"ok"
|
||||||
request.status_code = requests.codes.ok
|
request.status_code = requests.codes.ok
|
||||||
mock_post.return_value = request
|
mock_post.return_value = request
|
||||||
|
|
||||||
# Monkey Patch
|
# Monkey Patch
|
||||||
N_MGR['mailto'].enabled = True
|
N_MGR["mailto"].enabled = True
|
||||||
|
|
||||||
config = inspect.cleandoc("""
|
config = inspect.cleandoc(
|
||||||
|
"""
|
||||||
version: 1
|
version: 1
|
||||||
groups:
|
groups:
|
||||||
mygroup: user1, user2
|
mygroup: user1, user2
|
||||||
@@ -367,37 +361,35 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
tag: user1
|
tag: user1
|
||||||
- to: user2@example.com
|
- to: user2@example.com
|
||||||
tag: user2
|
tag: user2
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Monkey Patch
|
# Monkey Patch
|
||||||
N_MGR['json'].enabled = True
|
N_MGR["json"].enabled = True
|
||||||
|
|
||||||
# Add our content
|
# Add our content
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"config": config})
|
||||||
'/add/{}'.format(key),
|
|
||||||
{'config': config})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Now we should be able to see our content
|
# Now we should be able to see our content
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
for tag in ('user1', 'user2'):
|
for tag in ("user1", "user2"):
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'tag': tag,
|
"tag": tag,
|
||||||
}
|
}
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# We sent the notification successfully
|
# We sent the notification successfully
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Our single endpoint is notified
|
# Our single endpoint is notified
|
||||||
@@ -407,9 +399,9 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Now let's notify by our group
|
# Now let's notify by our group
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'tag': 'mygroup',
|
"tag": "mygroup",
|
||||||
}
|
}
|
||||||
|
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
@@ -417,11 +409,10 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# We sent the notification successfully
|
# We sent the notification successfully
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Our 2 endpoints are notified
|
# Our 2 endpoints are notified
|
||||||
@@ -430,30 +421,31 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
# Now empty our data
|
# Now empty our data
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Reset our count
|
# Reset our count
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
@patch('requests.post')
|
@patch("requests.post")
|
||||||
def test_stateful_group_dictlist_notify(self, mock_post):
|
def test_stateful_group_dictlist_notify(self, mock_post):
|
||||||
"""
|
"""
|
||||||
Test the handling of a group defined as a list of dictionaries
|
Test the handling of a group defined as a list of dictionaries
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# our key to use
|
# our key to use
|
||||||
key = 'test_stateful_group_notify_list_dict'
|
key = "test_stateful_group_notify_list_dict"
|
||||||
|
|
||||||
request = Mock()
|
request = Mock()
|
||||||
request.content = b'ok'
|
request.content = b"ok"
|
||||||
request.status_code = requests.codes.ok
|
request.status_code = requests.codes.ok
|
||||||
mock_post.return_value = request
|
mock_post.return_value = request
|
||||||
|
|
||||||
# Monkey Patch
|
# Monkey Patch
|
||||||
N_MGR['mailto'].enabled = True
|
N_MGR["mailto"].enabled = True
|
||||||
|
|
||||||
config = inspect.cleandoc("""
|
config = inspect.cleandoc(
|
||||||
|
"""
|
||||||
version: 1
|
version: 1
|
||||||
groups:
|
groups:
|
||||||
- mygroup: user1, user2
|
- mygroup: user1, user2
|
||||||
@@ -464,37 +456,35 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
tag: user1
|
tag: user1
|
||||||
- to: user2@example.com
|
- to: user2@example.com
|
||||||
tag: user2
|
tag: user2
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Monkey Patch
|
# Monkey Patch
|
||||||
N_MGR['json'].enabled = True
|
N_MGR["json"].enabled = True
|
||||||
|
|
||||||
# Add our content
|
# Add our content
|
||||||
response = self.client.post(
|
response = self.client.post("/add/{}".format(key), {"config": config})
|
||||||
'/add/{}'.format(key),
|
|
||||||
{'config': config})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Now we should be able to see our content
|
# Now we should be able to see our content
|
||||||
response = self.client.post('/get/{}'.format(key))
|
response = self.client.post("/get/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
for tag in ('user1', 'user2'):
|
for tag in ("user1", "user2"):
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'tag': tag,
|
"tag": tag,
|
||||||
}
|
}
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# We sent the notification successfully
|
# We sent the notification successfully
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Our single endpoint is notified
|
# Our single endpoint is notified
|
||||||
@@ -504,9 +494,9 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Now let's notify by our group
|
# Now let's notify by our group
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': '## test notification',
|
"body": "## test notification",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'tag': 'mygroup',
|
"tag": "mygroup",
|
||||||
}
|
}
|
||||||
|
|
||||||
form = NotifyForm(data=form_data)
|
form = NotifyForm(data=form_data)
|
||||||
@@ -514,11 +504,10 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# We sent the notification successfully
|
# We sent the notification successfully
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
|
||||||
'/notify/{}'.format(key), form.cleaned_data)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Our 2 endpoints are notified
|
# Our 2 endpoints are notified
|
||||||
@@ -527,7 +516,7 @@ class StatefulNotifyTests(SimpleTestCase):
|
|||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
# Now empty our data
|
# Now empty our data
|
||||||
response = self.client.post('/del/{}'.format(key))
|
response = self.client.post("/del/{}".format(key))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Reset our count
|
# Reset our count
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,16 +21,19 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.test import SimpleTestCase
|
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
||||||
from django.core.exceptions import RequestDataTooBig
|
|
||||||
from django.test.utils import override_settings
|
|
||||||
from unittest import mock
|
|
||||||
from ..forms import NotifyByUrlForm
|
|
||||||
import requests
|
|
||||||
import json
|
import json
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.exceptions import RequestDataTooBig
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
import requests
|
||||||
|
|
||||||
import apprise
|
import apprise
|
||||||
|
|
||||||
|
from ..forms import NotifyByUrlForm
|
||||||
|
|
||||||
# Grant access to our Notification Manager Singleton
|
# Grant access to our Notification Manager Singleton
|
||||||
N_MGR = apprise.manager_plugins.NotificationManager()
|
N_MGR = apprise.manager_plugins.NotificationManager()
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
Test stateless notifications
|
Test stateless notifications
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@mock.patch('apprise.Apprise.notify')
|
@mock.patch("apprise.Apprise.notify")
|
||||||
def test_notify(self, mock_notify):
|
def test_notify(self, mock_notify):
|
||||||
"""
|
"""
|
||||||
Test sending a simple notification
|
Test sending a simple notification
|
||||||
@@ -52,8 +54,8 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our form data
|
# Preare our form data
|
||||||
form_data = {
|
form_data = {
|
||||||
'urls': 'mailto://user:pass@hotmail.com',
|
"urls": "mailto://user:pass@hotmail.com",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
}
|
}
|
||||||
|
|
||||||
# At a minimum 'body' is requred
|
# At a minimum 'body' is requred
|
||||||
@@ -61,26 +63,26 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
# Required to prevent None from being passed into self.client.post()
|
# Required to prevent None from being passed into self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
response = self.client.post('/notify', form.cleaned_data)
|
response = self.client.post("/notify", form.cleaned_data)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
# Reset our count
|
# Reset our count
|
||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
form_data = {
|
form_data = {
|
||||||
'urls': 'mailto://user:pass@hotmail.com',
|
"urls": "mailto://user:pass@hotmail.com",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
}
|
}
|
||||||
form = NotifyByUrlForm(data=form_data)
|
form = NotifyByUrlForm(data=form_data)
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
# Required to prevent None from being passed into self.client.post()
|
# Required to prevent None from being passed into self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
response = self.client.post('/notify', form.cleaned_data)
|
response = self.client.post("/notify", form.cleaned_data)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
@@ -88,19 +90,22 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
# Test Headers
|
# Test Headers
|
||||||
for level in ('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG',
|
for level in (
|
||||||
'TRACE', 'INVALID'):
|
"CRITICAL",
|
||||||
|
"ERROR",
|
||||||
|
"WARNING",
|
||||||
|
"INFO",
|
||||||
|
"DEBUG",
|
||||||
|
"TRACE",
|
||||||
|
"INVALID",
|
||||||
|
):
|
||||||
form_data = {
|
form_data = {
|
||||||
'urls': 'mailto://user:pass@hotmail.com',
|
"urls": "mailto://user:pass@hotmail.com",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
attach_data = {
|
attach_data = {"attachment": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")}
|
||||||
'attachment': SimpleUploadedFile(
|
|
||||||
"attach.txt", b"content here", content_type="text/plain")
|
|
||||||
}
|
|
||||||
|
|
||||||
# At a minimum, just a body is required
|
# At a minimum, just a body is required
|
||||||
form = NotifyByUrlForm(form_data, attach_data)
|
form = NotifyByUrlForm(form_data, attach_data)
|
||||||
@@ -108,12 +113,11 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Prepare our header
|
# Prepare our header
|
||||||
headers = {
|
headers = {
|
||||||
'HTTP_X-APPRISE-LOG-LEVEL': level,
|
"HTTP_X-APPRISE-LOG-LEVEL": level,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send our notification
|
# Send our notification
|
||||||
response = self.client.post(
|
response = self.client.post("/notify", form.cleaned_data, **headers)
|
||||||
'/notify', form.cleaned_data, **headers)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
@@ -121,33 +125,32 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'payload': '## test notification',
|
"payload": "## test notification",
|
||||||
'fmt': apprise.NotifyFormat.MARKDOWN,
|
"fmt": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'extra': 'mailto://user:pass@hotmail.com',
|
"extra": "mailto://user:pass@hotmail.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
# We sent the notification successfully (use our rule mapping)
|
# We sent the notification successfully (use our rule mapping)
|
||||||
# FORM
|
# FORM
|
||||||
response = self.client.post(
|
response = self.client.post("/notify/?:payload=body&:fmt=format&:extra=urls", form_data)
|
||||||
'/notify/?:payload=body&:fmt=format&:extra=urls',
|
|
||||||
form_data)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'payload': '## test notification',
|
"payload": "## test notification",
|
||||||
'fmt': apprise.NotifyFormat.MARKDOWN,
|
"fmt": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
'extra': 'mailto://user:pass@hotmail.com',
|
"extra": "mailto://user:pass@hotmail.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
# We sent the notification successfully (use our rule mapping)
|
# We sent the notification successfully (use our rule mapping)
|
||||||
# JSON
|
# JSON
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify/?:payload=body&:fmt=format&:extra=urls',
|
"/notify/?:payload=body&:fmt=format&:extra=urls",
|
||||||
json.dumps(form_data),
|
json.dumps(form_data),
|
||||||
content_type="application/json")
|
content_type="application/json",
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
@@ -155,9 +158,11 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Long Filename
|
# Long Filename
|
||||||
attach_data = {
|
attach_data = {
|
||||||
'attachment': SimpleUploadedFile(
|
"attachment": SimpleUploadedFile(
|
||||||
"{}.txt".format('a' * 2000),
|
"{}.txt".format("a" * 2000),
|
||||||
b"content here", content_type="text/plain")
|
b"content here",
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
# At a minimum, just a body is required
|
# At a minimum, just a body is required
|
||||||
@@ -165,7 +170,7 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
# Send our notification
|
# Send our notification
|
||||||
response = self.client.post('/notify', form.cleaned_data)
|
response = self.client.post("/notify", form.cleaned_data)
|
||||||
|
|
||||||
# We fail because the filename is too long
|
# We fail because the filename is too long
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -175,20 +180,18 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
# Test Webhooks
|
# Test Webhooks
|
||||||
with mock.patch('requests.post') as mock_post:
|
with mock.patch("requests.post") as mock_post:
|
||||||
# Response object
|
# Response object
|
||||||
response = mock.Mock()
|
response = mock.Mock()
|
||||||
response.status_code = requests.codes.ok
|
response.status_code = requests.codes.ok
|
||||||
mock_post.return_value = response
|
mock_post.return_value = response
|
||||||
|
|
||||||
with override_settings(
|
with override_settings(APPRISE_WEBHOOK_URL="http://localhost/webhook/"):
|
||||||
APPRISE_WEBHOOK_URL='http://localhost/webhook/'):
|
|
||||||
|
|
||||||
# Preare our form data
|
# Preare our form data
|
||||||
form_data = {
|
form_data = {
|
||||||
'urls': 'mailto://user:pass@hotmail.com',
|
"urls": "mailto://user:pass@hotmail.com",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
'format': apprise.NotifyFormat.MARKDOWN,
|
"format": apprise.NotifyFormat.MARKDOWN.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
# At a minimum, just a body is required
|
# At a minimum, just a body is required
|
||||||
@@ -197,10 +200,10 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Required to prevent None from being passed into
|
# Required to prevent None from being passed into
|
||||||
# self.client.post()
|
# self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# Send our notification
|
# Send our notification
|
||||||
response = self.client.post('/notify', form.cleaned_data)
|
response = self.client.post("/notify", form.cleaned_data)
|
||||||
|
|
||||||
# Test our results
|
# Test our results
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -214,32 +217,32 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
form_data = {
|
form_data = {
|
||||||
'urls': 'mailto://user:pass@hotmail.com',
|
"urls": "mailto://user:pass@hotmail.com",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
# Invalid formats cause an error
|
# Invalid formats cause an error
|
||||||
'format': 'invalid'
|
"format": "invalid",
|
||||||
}
|
}
|
||||||
form = NotifyByUrlForm(data=form_data)
|
form = NotifyByUrlForm(data=form_data)
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
|
|
||||||
# Required to prevent None from being passed into self.client.post()
|
# Required to prevent None from being passed into self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# Send our notification
|
# Send our notification
|
||||||
response = self.client.post('/notify', form.cleaned_data)
|
response = self.client.post("/notify", form.cleaned_data)
|
||||||
|
|
||||||
# Test our results
|
# Test our results
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
@mock.patch('apprise.NotifyBase.notify')
|
@mock.patch("apprise.NotifyBase.notify")
|
||||||
def test_partial_notify(self, mock_notify):
|
def test_partial_notify(self, mock_notify):
|
||||||
"""
|
"""
|
||||||
Test sending multiple notifications where one fails
|
Test sending multiple notifications where one fails
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Ensure we're enabled for the purpose of our testing
|
# Ensure we're enabled for the purpose of our testing
|
||||||
N_MGR['mailto'].enabled = True
|
N_MGR["mailto"].enabled = True
|
||||||
|
|
||||||
# Set our return value; first we return a true, then we fail
|
# Set our return value; first we return a true, then we fail
|
||||||
# on the second call
|
# on the second call
|
||||||
@@ -247,11 +250,13 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our form data
|
# Preare our form data
|
||||||
form_data = {
|
form_data = {
|
||||||
'urls': ', '.join([
|
"urls": ", ".join(
|
||||||
'mailto://user:pass@hotmail.com',
|
[
|
||||||
'mailto://user:pass@gmail.com',
|
"mailto://user:pass@hotmail.com",
|
||||||
]),
|
"mailto://user:pass@gmail.com",
|
||||||
'body': 'test notifiction',
|
]
|
||||||
|
),
|
||||||
|
"body": "test notifiction",
|
||||||
}
|
}
|
||||||
|
|
||||||
# At a minimum 'body' is requred
|
# At a minimum 'body' is requred
|
||||||
@@ -259,9 +264,9 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
# Required to prevent None from being passed into self.client.post()
|
# Required to prevent None from being passed into self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
response = self.client.post('/notify', form.cleaned_data)
|
response = self.client.post("/notify", form.cleaned_data)
|
||||||
assert response.status_code == 424
|
assert response.status_code == 424
|
||||||
assert mock_notify.call_count == 2
|
assert mock_notify.call_count == 2
|
||||||
|
|
||||||
@@ -270,16 +275,18 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our form data
|
# Preare our form data
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
'urls': ', '.join([
|
"urls": ", ".join(
|
||||||
'mailto://user:pass@hotmail.com',
|
[
|
||||||
'mailto://user:pass@gmail.com',
|
"mailto://user:pass@hotmail.com",
|
||||||
]),
|
"mailto://user:pass@gmail.com",
|
||||||
'attachment': 'https://localhost/invalid/path/to/image.png',
|
]
|
||||||
|
),
|
||||||
|
"attachment": "https://localhost/invalid/path/to/image.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send our notification
|
# Send our notification
|
||||||
response = self.client.post('/notify', form_data)
|
response = self.client.post("/notify", form_data)
|
||||||
# We fail because we couldn't retrieve our attachment
|
# We fail because we couldn't retrieve our attachment
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert mock_notify.call_count == 0
|
assert mock_notify.call_count == 0
|
||||||
@@ -289,16 +296,18 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our form data (support attach keyword)
|
# Preare our form data (support attach keyword)
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
'urls': ', '.join([
|
"urls": ", ".join(
|
||||||
'mailto://user:pass@hotmail.com',
|
[
|
||||||
'mailto://user:pass@gmail.com',
|
"mailto://user:pass@hotmail.com",
|
||||||
]),
|
"mailto://user:pass@gmail.com",
|
||||||
'attach': 'https://localhost/invalid/path/to/image.png',
|
]
|
||||||
|
),
|
||||||
|
"attach": "https://localhost/invalid/path/to/image.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send our notification
|
# Send our notification
|
||||||
response = self.client.post('/notify', form_data)
|
response = self.client.post("/notify", form_data)
|
||||||
# We fail because we couldn't retrieve our attachment
|
# We fail because we couldn't retrieve our attachment
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert mock_notify.call_count == 0
|
assert mock_notify.call_count == 0
|
||||||
@@ -308,19 +317,21 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our json data (and support attach keyword as alias)
|
# Preare our json data (and support attach keyword as alias)
|
||||||
json_data = {
|
json_data = {
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
'urls': ', '.join([
|
"urls": ", ".join(
|
||||||
'mailto://user:pass@hotmail.com',
|
[
|
||||||
'mailto://user:pass@gmail.com',
|
"mailto://user:pass@hotmail.com",
|
||||||
]),
|
"mailto://user:pass@gmail.com",
|
||||||
'attach': 'https://localhost/invalid/path/to/image.png',
|
]
|
||||||
|
),
|
||||||
|
"attach": "https://localhost/invalid/path/to/image.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Same results
|
# Same results
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify/',
|
"/notify/",
|
||||||
data=json.dumps(json_data),
|
data=json.dumps(json_data),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# We fail because we couldn't retrieve our attachment
|
# We fail because we couldn't retrieve our attachment
|
||||||
@@ -328,7 +339,7 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
assert mock_notify.call_count == 0
|
assert mock_notify.call_count == 0
|
||||||
|
|
||||||
@override_settings(APPRISE_RECURSION_MAX=1)
|
@override_settings(APPRISE_RECURSION_MAX=1)
|
||||||
@mock.patch('apprise.Apprise.notify')
|
@mock.patch("apprise.Apprise.notify")
|
||||||
def test_stateless_notify_recursion(self, mock_notify):
|
def test_stateless_notify_recursion(self, mock_notify):
|
||||||
"""
|
"""
|
||||||
Test recursion an id header details as part of post
|
Test recursion an id header details as part of post
|
||||||
@@ -338,15 +349,15 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
mock_notify.return_value = True
|
mock_notify.return_value = True
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'HTTP_X-APPRISE-ID': 'abc123',
|
"HTTP_X-APPRISE-ID": "abc123",
|
||||||
'HTTP_X-APPRISE-RECURSION-COUNT': str(1),
|
"HTTP_X-APPRISE-RECURSION-COUNT": str(1),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Preare our form data (without url specified)
|
# Preare our form data (without url specified)
|
||||||
# content will fall back to default configuration
|
# content will fall back to default configuration
|
||||||
form_data = {
|
form_data = {
|
||||||
'urls': 'mailto://user:pass@hotmail.com',
|
"urls": "mailto://user:pass@hotmail.com",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Monkey Patch
|
# Monkey Patch
|
||||||
@@ -357,16 +368,16 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
# Required to prevent None from being passed into self.client.post()
|
# Required to prevent None from being passed into self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# recursion value is within correct limits
|
# recursion value is within correct limits
|
||||||
response = self.client.post('/notify', form.cleaned_data, **headers)
|
response = self.client.post("/notify", form.cleaned_data, **headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
# Header specified but with whitespace
|
# Header specified but with whitespace
|
||||||
'HTTP_X-APPRISE-ID': ' ',
|
"HTTP_X-APPRISE-ID": " ",
|
||||||
# No Recursion value specified
|
# No Recursion value specified
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,54 +385,54 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
# Recursion limit reached
|
# Recursion limit reached
|
||||||
response = self.client.post('/notify', form.cleaned_data, **headers)
|
response = self.client.post("/notify", form.cleaned_data, **headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'HTTP_X-APPRISE-ID': 'abc123',
|
"HTTP_X-APPRISE-ID": "abc123",
|
||||||
# Recursion Limit hit
|
# Recursion Limit hit
|
||||||
'HTTP_X-APPRISE-RECURSION-COUNT': str(2),
|
"HTTP_X-APPRISE-RECURSION-COUNT": str(2),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Reset our count
|
# Reset our count
|
||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
# Recursion limit reached
|
# Recursion limit reached
|
||||||
response = self.client.post('/notify', form.cleaned_data, **headers)
|
response = self.client.post("/notify", form.cleaned_data, **headers)
|
||||||
assert response.status_code == 406
|
assert response.status_code == 406
|
||||||
assert mock_notify.call_count == 0
|
assert mock_notify.call_count == 0
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'HTTP_X-APPRISE-ID': 'abc123',
|
"HTTP_X-APPRISE-ID": "abc123",
|
||||||
# Negative recursion value (bad request)
|
# Negative recursion value (bad request)
|
||||||
'HTTP_X-APPRISE-RECURSION-COUNT': str(-1),
|
"HTTP_X-APPRISE-RECURSION-COUNT": str(-1),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Reset our count
|
# Reset our count
|
||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
# invalid recursion specified
|
# invalid recursion specified
|
||||||
response = self.client.post('/notify', form.cleaned_data, **headers)
|
response = self.client.post("/notify", form.cleaned_data, **headers)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert mock_notify.call_count == 0
|
assert mock_notify.call_count == 0
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'HTTP_X-APPRISE-ID': 'abc123',
|
"HTTP_X-APPRISE-ID": "abc123",
|
||||||
# Invalid recursion value (bad request)
|
# Invalid recursion value (bad request)
|
||||||
'HTTP_X-APPRISE-RECURSION-COUNT': 'invalid',
|
"HTTP_X-APPRISE-RECURSION-COUNT": "invalid",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Reset our count
|
# Reset our count
|
||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
# invalid recursion specified
|
# invalid recursion specified
|
||||||
response = self.client.post('/notify', form.cleaned_data, **headers)
|
response = self.client.post("/notify", form.cleaned_data, **headers)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert mock_notify.call_count == 0
|
assert mock_notify.call_count == 0
|
||||||
|
|
||||||
@override_settings(APPRISE_STATELESS_URLS="mailto://user:pass@localhost")
|
@override_settings(APPRISE_STATELESS_URLS="mailto://user:pass@localhost")
|
||||||
@mock.patch('apprise.Apprise.notify')
|
@mock.patch("apprise.Apprise.notify")
|
||||||
def test_notify_default_urls(self, mock_notify):
|
def test_notify_default_urls(self, mock_notify):
|
||||||
"""
|
"""
|
||||||
Test fallback to default URLS if none were otherwise specified
|
Test fallback to default URLS if none were otherwise specified
|
||||||
@@ -434,7 +445,7 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
# Preare our form data (without url specified)
|
# Preare our form data (without url specified)
|
||||||
# content will fall back to default configuration
|
# content will fall back to default configuration
|
||||||
form_data = {
|
form_data = {
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
}
|
}
|
||||||
|
|
||||||
# At a minimum 'body' is requred
|
# At a minimum 'body' is requred
|
||||||
@@ -442,14 +453,14 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
# Required to prevent None from being passed into self.client.post()
|
# Required to prevent None from being passed into self.client.post()
|
||||||
del form.cleaned_data['attachment']
|
del form.cleaned_data["attachment"]
|
||||||
|
|
||||||
# This still works as the environment variable kicks in
|
# This still works as the environment variable kicks in
|
||||||
response = self.client.post('/notify', form.cleaned_data)
|
response = self.client.post("/notify", form.cleaned_data)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
|
|
||||||
@mock.patch('apprise.Apprise.notify')
|
@mock.patch("apprise.Apprise.notify")
|
||||||
def test_notify_with_get_parameters(self, mock_notify):
|
def test_notify_with_get_parameters(self, mock_notify):
|
||||||
"""
|
"""
|
||||||
Test sending a simple notification using JSON with GET
|
Test sending a simple notification using JSON with GET
|
||||||
@@ -461,15 +472,15 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our JSON data
|
# Preare our JSON data
|
||||||
json_data = {
|
json_data = {
|
||||||
'urls': 'json://user@my.domain.ca',
|
"urls": "json://user@my.domain.ca",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send our notification as a JSON object
|
# Send our notification as a JSON object
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify/?title=my%20title&format=text&type=info',
|
"/notify/?title=my%20title&format=text&type=info",
|
||||||
data=json.dumps(json_data),
|
data=json.dumps(json_data),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Still supported
|
# Still supported
|
||||||
@@ -479,48 +490,48 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
# Reset our count
|
# Reset our count
|
||||||
mock_notify.reset_mock()
|
mock_notify.reset_mock()
|
||||||
|
|
||||||
with mock.patch('json.loads') as mock_loads:
|
with mock.patch("json.loads") as mock_loads:
|
||||||
mock_loads.side_effect = RequestDataTooBig()
|
mock_loads.side_effect = RequestDataTooBig()
|
||||||
# Send our notification
|
# Send our notification
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify/?title=my%20title&format=text&type=info',
|
"/notify/?title=my%20title&format=text&type=info",
|
||||||
data=json.dumps(json_data),
|
data=json.dumps(json_data),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Our notification failed
|
# Our notification failed
|
||||||
assert response.status_code == 431
|
assert response.status_code == 431
|
||||||
assert mock_notify.call_count == 0
|
assert mock_notify.call_count == 0
|
||||||
|
|
||||||
@mock.patch('apprise.Apprise.notify')
|
@mock.patch("apprise.Apprise.notify")
|
||||||
def test_notify_html_response_block(self, mock_notify):
|
def test_notify_html_response_block(self, mock_notify):
|
||||||
"""
|
"""
|
||||||
Test HTML log formatting block is triggered in StatelessNotifyView
|
Test HTML log formatting block is triggered in StatelessNotifyView
|
||||||
"""
|
"""
|
||||||
mock_notify.return_value = True
|
mock_notify.return_value = True
|
||||||
form_data = {
|
form_data = {
|
||||||
'urls': 'json://user@localhost',
|
"urls": "json://user@localhost",
|
||||||
'body': 'Testing HTML block',
|
"body": "Testing HTML block",
|
||||||
'type': apprise.NotifyType.INFO,
|
"type": apprise.NotifyType.INFO.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'HTTP_Accept': 'text/html',
|
"HTTP_Accept": "text/html",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify',
|
"/notify",
|
||||||
data=form_data,
|
data=form_data,
|
||||||
**headers,
|
**headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_notify.call_count == 1
|
assert mock_notify.call_count == 1
|
||||||
assert response['Content-Type'].startswith('text/html')
|
assert response["Content-Type"].startswith("text/html")
|
||||||
assert b'<ul class="logs">' in response.content
|
assert b'<ul class="logs">' in response.content
|
||||||
assert b'class="logs"' in response.content
|
assert b'class="logs"' in response.content
|
||||||
|
|
||||||
@mock.patch('apprise.Apprise.notify')
|
@mock.patch("apprise.Apprise.notify")
|
||||||
def test_notify_by_loaded_urls_with_json(self, mock_notify):
|
def test_notify_by_loaded_urls_with_json(self, mock_notify):
|
||||||
"""
|
"""
|
||||||
Test sending a simple notification using JSON
|
Test sending a simple notification using JSON
|
||||||
@@ -531,16 +542,16 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our JSON data without any urls
|
# Preare our JSON data without any urls
|
||||||
json_data = {
|
json_data = {
|
||||||
'urls': '',
|
"urls": "",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
'type': apprise.NotifyType.WARNING,
|
"type": apprise.NotifyType.WARNING.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send our empty notification as a JSON object
|
# Send our empty notification as a JSON object
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify',
|
"/notify",
|
||||||
data=json.dumps(json_data),
|
data=json.dumps(json_data),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Nothing notified
|
# Nothing notified
|
||||||
@@ -549,16 +560,16 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our JSON data
|
# Preare our JSON data
|
||||||
json_data = {
|
json_data = {
|
||||||
'urls': 'mailto://user:pass@yahoo.ca',
|
"urls": "mailto://user:pass@yahoo.ca",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
'type': apprise.NotifyType.WARNING,
|
"type": apprise.NotifyType.WARNING.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send our notification as a JSON object
|
# Send our notification as a JSON object
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify',
|
"/notify",
|
||||||
data=json.dumps(json_data),
|
data=json.dumps(json_data),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Still supported
|
# Still supported
|
||||||
@@ -570,9 +581,9 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Test sending a garbage JSON object
|
# Test sending a garbage JSON object
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify/',
|
"/notify/",
|
||||||
data="{",
|
data="{",
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -580,9 +591,9 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Test sending with an invalid content type
|
# Test sending with an invalid content type
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify',
|
"/notify",
|
||||||
data="{}",
|
data="{}",
|
||||||
content_type='application/xml',
|
content_type="application/xml",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -590,9 +601,9 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Test sending without any content at all
|
# Test sending without any content at all
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify/',
|
"/notify/",
|
||||||
data="{}",
|
data="{}",
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -600,13 +611,13 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Test sending without a body
|
# Test sending without a body
|
||||||
json_data = {
|
json_data = {
|
||||||
'type': apprise.NotifyType.WARNING,
|
"type": apprise.NotifyType.WARNING.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify',
|
"/notify",
|
||||||
data=json.dumps(json_data),
|
data=json.dumps(json_data),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -617,24 +628,24 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our JSON data
|
# Preare our JSON data
|
||||||
json_data = {
|
json_data = {
|
||||||
'urls': 'mailto://user:pass@yahoo.ca',
|
"urls": "mailto://user:pass@yahoo.ca",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
# invalid server side format
|
# invalid server side format
|
||||||
'format': 'invalid'
|
"format": "invalid",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send our notification as a JSON object
|
# Send our notification as a JSON object
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify',
|
"/notify",
|
||||||
data=json.dumps(json_data),
|
data=json.dumps(json_data),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Still supported
|
# Still supported
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert mock_notify.call_count == 0
|
assert mock_notify.call_count == 0
|
||||||
|
|
||||||
@mock.patch('apprise.plugins.custom_json.NotifyJSON.send')
|
@mock.patch("apprise.plugins.custom_json.NotifyJSON.send")
|
||||||
def test_notify_with_filters(self, mock_send):
|
def test_notify_with_filters(self, mock_send):
|
||||||
"""
|
"""
|
||||||
Test workings of APPRISE_DENY_SERVICES and APPRISE_ALLOW_SERVICES
|
Test workings of APPRISE_DENY_SERVICES and APPRISE_ALLOW_SERVICES
|
||||||
@@ -645,145 +656,143 @@ class StatelessNotifyTests(SimpleTestCase):
|
|||||||
|
|
||||||
# Preare our JSON data
|
# Preare our JSON data
|
||||||
json_data = {
|
json_data = {
|
||||||
'urls': 'json://user:pass@yahoo.ca',
|
"urls": "json://user:pass@yahoo.ca",
|
||||||
'body': 'test notifiction',
|
"body": "test notifiction",
|
||||||
'type': apprise.NotifyType.WARNING,
|
"type": apprise.NotifyType.WARNING.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send our notification as a JSON object
|
# Send our notification as a JSON object
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify',
|
"/notify",
|
||||||
data=json.dumps(json_data),
|
data=json.dumps(json_data),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure we're enabled for the purpose of our testing
|
# Ensure we're enabled for the purpose of our testing
|
||||||
N_MGR['json'].enabled = True
|
N_MGR["json"].enabled = True
|
||||||
|
|
||||||
# Reset Mock
|
# Reset Mock
|
||||||
mock_send.reset_mock()
|
mock_send.reset_mock()
|
||||||
|
|
||||||
# Send our service with the `json://` denied
|
# Send our service with the `json://` denied
|
||||||
with override_settings(APPRISE_ALLOW_SERVICES=""):
|
with override_settings(APPRISE_ALLOW_SERVICES=""), \
|
||||||
# Test our stateless storage setting (just to kill 2 birds with 1 stone)
|
override_settings(APPRISE_STATELESS_STORAGE="yes"), \
|
||||||
with override_settings(APPRISE_STATELESS_STORAGE="yes"):
|
override_settings(APPRISE_DENY_SERVICES="json"):
|
||||||
with override_settings(APPRISE_DENY_SERVICES="json"):
|
|
||||||
# Send our notification as a JSON object
|
|
||||||
response = self.client.post(
|
|
||||||
'/notify',
|
|
||||||
data=json.dumps(json_data),
|
|
||||||
content_type='application/json',
|
|
||||||
)
|
|
||||||
|
|
||||||
# json:// is disabled
|
# Send our notification as a JSON object
|
||||||
assert response.status_code == 204
|
response = self.client.post(
|
||||||
assert mock_send.call_count == 0
|
"/notify",
|
||||||
|
data=json.dumps(json_data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
# What actually took place behind close doors:
|
# json:// is disabled
|
||||||
assert N_MGR['json'].enabled is False
|
assert response.status_code == 204
|
||||||
|
assert mock_send.call_count == 0
|
||||||
|
|
||||||
# Reset our flag (for next test)
|
# What actually took place behind close doors:
|
||||||
N_MGR['json'].enabled = True
|
assert N_MGR["json"].enabled is False
|
||||||
|
|
||||||
|
# Reset our flag (for next test)
|
||||||
|
N_MGR["json"].enabled = True
|
||||||
|
|
||||||
# Reset Mock
|
# Reset Mock
|
||||||
mock_send.reset_mock()
|
mock_send.reset_mock()
|
||||||
|
|
||||||
# Send our service with the `json://` denied
|
# Send our service with the `json://` denied
|
||||||
with override_settings(APPRISE_ALLOW_SERVICES=""):
|
with override_settings(APPRISE_ALLOW_SERVICES=""), \
|
||||||
with override_settings(APPRISE_DENY_SERVICES="invalid, syslog"):
|
override_settings(APPRISE_DENY_SERVICES="invalid, syslog"):
|
||||||
# Send our notification as a JSON object
|
|
||||||
response = self.client.post(
|
|
||||||
'/notify',
|
|
||||||
data=json.dumps(json_data),
|
|
||||||
content_type='application/json',
|
|
||||||
)
|
|
||||||
|
|
||||||
# json:// is enabled
|
# Send our notification as a JSON object
|
||||||
assert response.status_code == 200
|
response = self.client.post(
|
||||||
assert mock_send.call_count == 1
|
"/notify",
|
||||||
|
data=json.dumps(json_data),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
# Verify that json was never turned off
|
# json:// is enabled
|
||||||
assert N_MGR['json'].enabled is True
|
assert response.status_code == 200
|
||||||
|
assert mock_send.call_count == 1
|
||||||
|
|
||||||
|
# Verify that json was never turned off
|
||||||
|
assert N_MGR["json"].enabled is True
|
||||||
|
|
||||||
# Reset Mock
|
# Reset Mock
|
||||||
mock_send.reset_mock()
|
mock_send.reset_mock()
|
||||||
|
|
||||||
# Send our service with the `json://` being the only accepted type
|
# Send our service with the `json://` being the only accepted type
|
||||||
with override_settings(APPRISE_ALLOW_SERVICES="json"):
|
with override_settings(APPRISE_ALLOW_SERVICES="json"), override_settings(APPRISE_DENY_SERVICES=""):
|
||||||
with override_settings(APPRISE_DENY_SERVICES=""):
|
# Send our notification as a JSON object
|
||||||
# Send our notification as a JSON object
|
response = self.client.post(
|
||||||
response = self.client.post(
|
"/notify",
|
||||||
'/notify',
|
data=json.dumps(json_data),
|
||||||
data=json.dumps(json_data),
|
content_type="application/json",
|
||||||
content_type='application/json',
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# json:// is enabled
|
# json:// is enabled
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_send.call_count == 1
|
assert mock_send.call_count == 1
|
||||||
|
|
||||||
# Verify email was never turned off
|
# Verify email was never turned off
|
||||||
assert N_MGR['json'].enabled is True
|
assert N_MGR["json"].enabled is True
|
||||||
|
|
||||||
# Reset Mock
|
# Reset Mock
|
||||||
mock_send.reset_mock()
|
mock_send.reset_mock()
|
||||||
|
|
||||||
# Send our service with the `json://` being the only accepted type
|
# Send our service with the `json://` being the only accepted type
|
||||||
with override_settings(APPRISE_ALLOW_SERVICES="invalid, jsons"):
|
with override_settings(APPRISE_ALLOW_SERVICES="invalid, jsons"), \
|
||||||
with override_settings(APPRISE_DENY_SERVICES=""):
|
override_settings(APPRISE_DENY_SERVICES=""):
|
||||||
# Send our notification as a JSON object
|
# Send our notification as a JSON object
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/notify',
|
"/notify",
|
||||||
data=json.dumps(json_data),
|
data=json.dumps(json_data),
|
||||||
content_type='application/json',
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
# json:// is enabled
|
# json:// is enabled
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_send.call_count == 1
|
assert mock_send.call_count == 1
|
||||||
|
|
||||||
# Verify email was never turned off
|
# Verify email was never turned off
|
||||||
assert N_MGR['json'].enabled is True
|
assert N_MGR["json"].enabled is True
|
||||||
|
|
||||||
# Reset Mock
|
# Reset Mock
|
||||||
mock_send.reset_mock()
|
mock_send.reset_mock()
|
||||||
|
|
||||||
# Send our service with the `json://` being the only accepted type
|
# Send our service with the `json://` being the only accepted type
|
||||||
with override_settings(APPRISE_ALLOW_SERVICES="syslog"):
|
with override_settings(APPRISE_ALLOW_SERVICES="syslog"), override_settings(APPRISE_DENY_SERVICES=""):
|
||||||
with override_settings(APPRISE_DENY_SERVICES=""):
|
# Send our notification as a JSON object
|
||||||
# Send our notification as a JSON object
|
response = self.client.post(
|
||||||
response = self.client.post(
|
"/notify",
|
||||||
'/notify',
|
data=json.dumps(json_data),
|
||||||
data=json.dumps(json_data),
|
content_type="application/json",
|
||||||
content_type='application/json',
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# json:// is disabled
|
# json:// is disabled
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert mock_send.call_count == 0
|
assert mock_send.call_count == 0
|
||||||
|
|
||||||
# What actually took place behind close doors:
|
# What actually took place behind close doors:
|
||||||
assert N_MGR['json'].enabled is False
|
assert N_MGR["json"].enabled is False
|
||||||
|
|
||||||
# Reset our flag (for next test)
|
# Reset our flag (for next test)
|
||||||
N_MGR['json'].enabled = True
|
N_MGR["json"].enabled = True
|
||||||
|
|
||||||
# Reset Mock
|
# Reset Mock
|
||||||
mock_send.reset_mock()
|
mock_send.reset_mock()
|
||||||
|
|
||||||
# Test case where there is simply no over-rides defined
|
# Test case where there is simply no over-rides defined
|
||||||
with override_settings(APPRISE_ALLOW_SERVICES=""):
|
with override_settings(APPRISE_ALLOW_SERVICES=""), override_settings(APPRISE_DENY_SERVICES=""):
|
||||||
with override_settings(APPRISE_DENY_SERVICES=""):
|
# Send our notification as a JSON object
|
||||||
# Send our notification as a JSON object
|
response = self.client.post(
|
||||||
response = self.client.post(
|
"/notify",
|
||||||
'/notify',
|
data=json.dumps(json_data),
|
||||||
data=json.dumps(json_data),
|
content_type="application/json",
|
||||||
content_type='application/json',
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# json:// is disabled
|
# json:// is disabled
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert mock_send.call_count == 1
|
assert mock_send.call_count == 1
|
||||||
|
|
||||||
# nothing was changed
|
# nothing was changed
|
||||||
assert N_MGR['json'].enabled is True
|
assert N_MGR["json"].enabled is True
|
||||||
|
43
apprise_api/api/tests/test_theme_middleware.py
Normal file
43
apprise_api/api/tests/test_theme_middleware.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
# This code is licensed under the MIT License.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files(the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions :
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import RequestFactory, SimpleTestCase
|
||||||
|
|
||||||
|
from apprise_api.core.middleware.theme import AutoThemeMiddleware, SiteTheme
|
||||||
|
|
||||||
|
|
||||||
|
class AutoThemeMiddlewareTest(SimpleTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
def test_theme_fallback_when_invalid_theme_given(self):
|
||||||
|
request = self.factory.get("/", {"theme": "foobar"})
|
||||||
|
request.COOKIES = {}
|
||||||
|
|
||||||
|
def get_response(req):
|
||||||
|
return HttpResponse()
|
||||||
|
|
||||||
|
middleware = AutoThemeMiddleware(get_response)
|
||||||
|
_response = middleware(request)
|
||||||
|
|
||||||
|
self.assertEqual(request.theme, SiteTheme.LIGHT)
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
from ..urlfilter import AppriseURLFilter
|
from ..urlfilter import AppriseURLFilter
|
||||||
|
|
||||||
|
|
||||||
@@ -32,289 +32,295 @@ class AttachmentTests(SimpleTestCase):
|
|||||||
Test the apprise url filter
|
Test the apprise url filter
|
||||||
"""
|
"""
|
||||||
# empty allow and deny lists
|
# empty allow and deny lists
|
||||||
af = AppriseURLFilter('', '')
|
af = AppriseURLFilter("", "")
|
||||||
|
|
||||||
# Test garbage entries
|
# Test garbage entries
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# These ar blocked too since we have no allow list
|
# These ar blocked too since we have no allow list
|
||||||
self.assertFalse(af.is_allowed('http://localhost'))
|
self.assertFalse(af.is_allowed("http://localhost"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost'))
|
self.assertFalse(af.is_allowed("http://localhost"))
|
||||||
|
|
||||||
#
|
#
|
||||||
# We have a wildcard for accept all in our allow list
|
# We have a wildcard for accept all in our allow list
|
||||||
#
|
#
|
||||||
af = AppriseURLFilter('*', '')
|
af = AppriseURLFilter("*", "")
|
||||||
|
|
||||||
# We still block junk
|
# We still block junk
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# We however allow localhost now (caught with *)
|
# We however allow localhost now (caught with *)
|
||||||
self.assertTrue(af.is_allowed('http://localhost'))
|
self.assertTrue(af.is_allowed("http://localhost"))
|
||||||
self.assertTrue(af.is_allowed('http://localhost/resources'))
|
self.assertTrue(af.is_allowed("http://localhost/resources"))
|
||||||
self.assertTrue(af.is_allowed('http://localhost/images'))
|
self.assertTrue(af.is_allowed("http://localhost/images"))
|
||||||
|
|
||||||
#
|
#
|
||||||
# Allow list accepts all, except we want to explicitely block https://localhost/resources
|
# Allow list accepts all, except we want to explicitely block https://localhost/resources
|
||||||
#
|
#
|
||||||
af = AppriseURLFilter('*', 'https://localhost/resources')
|
af = AppriseURLFilter("*", "https://localhost/resources")
|
||||||
|
|
||||||
# We still block junk
|
# We still block junk
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# Takeaway is https:// was blocked to resources, but not http:// request
|
# Takeaway is https:// was blocked to resources, but not http:// request
|
||||||
# because it was explicitly identify as so:
|
# because it was explicitly identify as so:
|
||||||
self.assertTrue(af.is_allowed('http://localhost'))
|
self.assertTrue(af.is_allowed("http://localhost"))
|
||||||
self.assertTrue(af.is_allowed('http://localhost/resources'))
|
self.assertTrue(af.is_allowed("http://localhost/resources"))
|
||||||
self.assertTrue(af.is_allowed('http://localhost/resources/sub/path/'))
|
self.assertTrue(af.is_allowed("http://localhost/resources/sub/path/"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources'))
|
self.assertFalse(af.is_allowed("https://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path/'))
|
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path/"))
|
||||||
self.assertTrue(af.is_allowed('http://localhost/images'))
|
self.assertTrue(af.is_allowed("http://localhost/images"))
|
||||||
|
|
||||||
#
|
#
|
||||||
# Allow list accepts all, except we want to explicitely block both
|
# Allow list accepts all, except we want to explicitely block both
|
||||||
# https://localhost/resources and http://localhost/resources
|
# https://localhost/resources and http://localhost/resources
|
||||||
#
|
#
|
||||||
af = AppriseURLFilter('*', 'localhost/resources')
|
af = AppriseURLFilter("*", "localhost/resources")
|
||||||
|
|
||||||
# We still block junk
|
# We still block junk
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# Takeaway is https:// was blocked to resources, but not http:// request
|
# Takeaway is https:// was blocked to resources, but not http:// request
|
||||||
# because it was explicitly identify as so:
|
# because it was explicitly identify as so:
|
||||||
self.assertTrue(af.is_allowed('http://localhost'))
|
self.assertTrue(af.is_allowed("http://localhost"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/resources'))
|
self.assertFalse(af.is_allowed("http://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/resources/sub/path'))
|
self.assertFalse(af.is_allowed("http://localhost/resources/sub/path"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources'))
|
self.assertFalse(af.is_allowed("https://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path/'))
|
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path/"))
|
||||||
self.assertTrue(af.is_allowed('http://localhost/images'))
|
self.assertTrue(af.is_allowed("http://localhost/images"))
|
||||||
|
|
||||||
#
|
#
|
||||||
# A more restrictive allow/block list
|
# A more restrictive allow/block list
|
||||||
# https://localhost/resources and http://localhost/resources
|
# https://localhost/resources and http://localhost/resources
|
||||||
#
|
#
|
||||||
af = AppriseURLFilter('https://localhost, http://myserver.*', 'localhost/resources')
|
af = AppriseURLFilter("https://localhost, http://myserver.*", "localhost/resources")
|
||||||
|
|
||||||
# We still block junk
|
# We still block junk
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# Explicitly only allows https
|
# Explicitly only allows https
|
||||||
self.assertFalse(af.is_allowed('http://localhost'))
|
self.assertFalse(af.is_allowed("http://localhost"))
|
||||||
self.assertTrue(af.is_allowed('https://localhost'))
|
self.assertTrue(af.is_allowed("https://localhost"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost:8000'))
|
self.assertFalse(af.is_allowed("https://localhost:8000"))
|
||||||
self.assertTrue(af.is_allowed('https://localhost/images'))
|
self.assertTrue(af.is_allowed("https://localhost/images"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources'))
|
self.assertFalse(af.is_allowed("https://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path/'))
|
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path/"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/resources'))
|
self.assertFalse(af.is_allowed("http://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/resources/sub/path'))
|
self.assertFalse(af.is_allowed("http://localhost/resources/sub/path"))
|
||||||
self.assertFalse(af.is_allowed('http://not-in-list'))
|
self.assertFalse(af.is_allowed("http://not-in-list"))
|
||||||
|
|
||||||
# Explicitly definition of allowed hostname prohibits the below from working:
|
# Explicitly definition of allowed hostname prohibits the below from working:
|
||||||
self.assertFalse(af.is_allowed('localhost'))
|
self.assertFalse(af.is_allowed("localhost"))
|
||||||
|
|
||||||
#
|
#
|
||||||
# Testing of hostnames only and ports
|
# Testing of hostnames only and ports
|
||||||
#
|
#
|
||||||
af = AppriseURLFilter('localhost, myserver:3000', 'localhost/resources')
|
af = AppriseURLFilter("localhost, myserver:3000", "localhost/resources")
|
||||||
|
|
||||||
# We still block junk
|
# We still block junk
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# all forms of localhost is allowed (provided there is no port)
|
# all forms of localhost is allowed (provided there is no port)
|
||||||
self.assertTrue(af.is_allowed('http://localhost'))
|
self.assertTrue(af.is_allowed("http://localhost"))
|
||||||
self.assertTrue(af.is_allowed('https://localhost'))
|
self.assertTrue(af.is_allowed("https://localhost"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost:8000'))
|
self.assertFalse(af.is_allowed("https://localhost:8000"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost:80'))
|
self.assertFalse(af.is_allowed("https://localhost:80"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost:443'))
|
self.assertFalse(af.is_allowed("https://localhost:443"))
|
||||||
self.assertTrue(af.is_allowed('https://localhost/images'))
|
self.assertTrue(af.is_allowed("https://localhost/images"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources'))
|
self.assertFalse(af.is_allowed("https://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path'))
|
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/resources'))
|
self.assertFalse(af.is_allowed("http://localhost/resources"))
|
||||||
self.assertTrue(af.is_allowed('http://localhost/resourcesssssssss'))
|
self.assertTrue(af.is_allowed("http://localhost/resourcesssssssss"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/resources/sub/path/'))
|
self.assertFalse(af.is_allowed("http://localhost/resources/sub/path/"))
|
||||||
self.assertFalse(af.is_allowed('http://not-in-list'))
|
self.assertFalse(af.is_allowed("http://not-in-list"))
|
||||||
|
|
||||||
# myserver is only allowed if port is provided
|
# myserver is only allowed if port is provided
|
||||||
self.assertFalse(af.is_allowed('http://myserver'))
|
self.assertFalse(af.is_allowed("http://myserver"))
|
||||||
self.assertFalse(af.is_allowed('https://myserver'))
|
self.assertFalse(af.is_allowed("https://myserver"))
|
||||||
self.assertTrue(af.is_allowed('http://myserver:3000'))
|
self.assertTrue(af.is_allowed("http://myserver:3000"))
|
||||||
self.assertTrue(af.is_allowed('https://myserver:3000'))
|
self.assertTrue(af.is_allowed("https://myserver:3000"))
|
||||||
|
|
||||||
# Open range of hosts allows these to be accepted:
|
# Open range of hosts allows these to be accepted:
|
||||||
self.assertTrue(af.is_allowed('localhost'))
|
self.assertTrue(af.is_allowed("localhost"))
|
||||||
self.assertTrue(af.is_allowed('myserver:3000'))
|
self.assertTrue(af.is_allowed("myserver:3000"))
|
||||||
self.assertTrue(af.is_allowed('https://myserver:3000'))
|
self.assertTrue(af.is_allowed("https://myserver:3000"))
|
||||||
self.assertTrue(af.is_allowed('http://myserver:3000'))
|
self.assertTrue(af.is_allowed("http://myserver:3000"))
|
||||||
|
|
||||||
#
|
#
|
||||||
# Testing of hostnames only and ports but via URLs (explicit http://)
|
# Testing of hostnames only and ports but via URLs (explicit http://)
|
||||||
# Also tests path ending with `/` (slash)
|
# Also tests path ending with `/` (slash)
|
||||||
#
|
#
|
||||||
af = AppriseURLFilter('http://localhost, http://myserver:3000', 'http://localhost/resources/')
|
af = AppriseURLFilter(
|
||||||
|
"http://localhost, http://myserver:3000",
|
||||||
|
"http://localhost/resources/",
|
||||||
|
)
|
||||||
|
|
||||||
# We still block junk
|
# We still block junk
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# http://localhost acceptance only
|
# http://localhost acceptance only
|
||||||
self.assertTrue(af.is_allowed('http://localhost'))
|
self.assertTrue(af.is_allowed("http://localhost"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost'))
|
self.assertFalse(af.is_allowed("https://localhost"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost:8000'))
|
self.assertFalse(af.is_allowed("http://localhost:8000"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost:80'))
|
self.assertFalse(af.is_allowed("http://localhost:80"))
|
||||||
self.assertTrue(af.is_allowed('http://localhost/images'))
|
self.assertTrue(af.is_allowed("http://localhost/images"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/images'))
|
self.assertFalse(af.is_allowed("https://localhost/images"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources'))
|
self.assertFalse(af.is_allowed("https://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path'))
|
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/resources'))
|
self.assertFalse(af.is_allowed("http://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('http://not-in-list'))
|
self.assertFalse(af.is_allowed("http://not-in-list"))
|
||||||
|
|
||||||
# myserver is only allowed if port is provided and http://
|
# myserver is only allowed if port is provided and http://
|
||||||
self.assertFalse(af.is_allowed('http://myserver'))
|
self.assertFalse(af.is_allowed("http://myserver"))
|
||||||
self.assertFalse(af.is_allowed('https://myserver'))
|
self.assertFalse(af.is_allowed("https://myserver"))
|
||||||
self.assertTrue(af.is_allowed('http://myserver:3000'))
|
self.assertTrue(af.is_allowed("http://myserver:3000"))
|
||||||
self.assertFalse(af.is_allowed('https://myserver:3000'))
|
self.assertFalse(af.is_allowed("https://myserver:3000"))
|
||||||
self.assertTrue(af.is_allowed('http://myserver:3000/path/'))
|
self.assertTrue(af.is_allowed("http://myserver:3000/path/"))
|
||||||
|
|
||||||
# Open range of hosts is no longer allowed due to explicit http:// reference
|
# Open range of hosts is no longer allowed due to explicit http:// reference
|
||||||
self.assertFalse(af.is_allowed('localhost'))
|
self.assertFalse(af.is_allowed("localhost"))
|
||||||
self.assertFalse(af.is_allowed('myserver:3000'))
|
self.assertFalse(af.is_allowed("myserver:3000"))
|
||||||
self.assertFalse(af.is_allowed('https://myserver:3000'))
|
self.assertFalse(af.is_allowed("https://myserver:3000"))
|
||||||
|
|
||||||
#
|
#
|
||||||
# Testing of hostnames only and ports but via URLs (explicit https://)
|
# Testing of hostnames only and ports but via URLs (explicit https://)
|
||||||
# Also tests path ending with `/` (slash)
|
# Also tests path ending with `/` (slash)
|
||||||
#
|
#
|
||||||
af = AppriseURLFilter('https://localhost, https://myserver:3000', 'https://localhost/resources/')
|
af = AppriseURLFilter(
|
||||||
|
"https://localhost, https://myserver:3000",
|
||||||
|
"https://localhost/resources/",
|
||||||
|
)
|
||||||
|
|
||||||
# We still block junk
|
# We still block junk
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# http://localhost acceptance only
|
# http://localhost acceptance only
|
||||||
self.assertTrue(af.is_allowed('https://localhost'))
|
self.assertTrue(af.is_allowed("https://localhost"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost'))
|
self.assertFalse(af.is_allowed("http://localhost"))
|
||||||
self.assertFalse(af.is_allowed('localhost'))
|
self.assertFalse(af.is_allowed("localhost"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost:8000'))
|
self.assertFalse(af.is_allowed("https://localhost:8000"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost:80'))
|
self.assertFalse(af.is_allowed("https://localhost:80"))
|
||||||
self.assertTrue(af.is_allowed('https://localhost/images'))
|
self.assertTrue(af.is_allowed("https://localhost/images"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/images'))
|
self.assertFalse(af.is_allowed("http://localhost/images"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/resources'))
|
self.assertFalse(af.is_allowed("http://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/resources/sub/path'))
|
self.assertFalse(af.is_allowed("http://localhost/resources/sub/path"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/resources'))
|
self.assertFalse(af.is_allowed("https://localhost/resources"))
|
||||||
self.assertFalse(af.is_allowed('https://not-in-list'))
|
self.assertFalse(af.is_allowed("https://not-in-list"))
|
||||||
|
|
||||||
# myserver is only allowed if port is provided and http://
|
# myserver is only allowed if port is provided and http://
|
||||||
self.assertFalse(af.is_allowed('https://myserver'))
|
self.assertFalse(af.is_allowed("https://myserver"))
|
||||||
self.assertFalse(af.is_allowed('http://myserver'))
|
self.assertFalse(af.is_allowed("http://myserver"))
|
||||||
self.assertFalse(af.is_allowed('myserver'))
|
self.assertFalse(af.is_allowed("myserver"))
|
||||||
self.assertTrue(af.is_allowed('https://myserver:3000'))
|
self.assertTrue(af.is_allowed("https://myserver:3000"))
|
||||||
self.assertFalse(af.is_allowed('http://myserver:3000'))
|
self.assertFalse(af.is_allowed("http://myserver:3000"))
|
||||||
self.assertTrue(af.is_allowed('https://myserver:3000/path/'))
|
self.assertTrue(af.is_allowed("https://myserver:3000/path/"))
|
||||||
|
|
||||||
# Open range of hosts is no longer allowed due to explicit http:// reference
|
# Open range of hosts is no longer allowed due to explicit http:// reference
|
||||||
self.assertFalse(af.is_allowed('localhost'))
|
self.assertFalse(af.is_allowed("localhost"))
|
||||||
self.assertFalse(af.is_allowed('myserver:3000'))
|
self.assertFalse(af.is_allowed("myserver:3000"))
|
||||||
self.assertFalse(af.is_allowed('http://myserver:3000'))
|
self.assertFalse(af.is_allowed("http://myserver:3000"))
|
||||||
|
|
||||||
#
|
#
|
||||||
# Testing Regular Expressions
|
# Testing Regular Expressions
|
||||||
#
|
#
|
||||||
af = AppriseURLFilter('https://localhost/incoming/*/*', 'https://localhost/*/*/var')
|
af = AppriseURLFilter("https://localhost/incoming/*/*", "https://localhost/*/*/var")
|
||||||
|
|
||||||
# We still block junk
|
# We still block junk
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# Very specific paths are supported now in https://localhost only:
|
# Very specific paths are supported now in https://localhost only:
|
||||||
self.assertFalse(af.is_allowed('https://localhost'))
|
self.assertFalse(af.is_allowed("https://localhost"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost'))
|
self.assertFalse(af.is_allowed("http://localhost"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/incoming'))
|
self.assertFalse(af.is_allowed("https://localhost/incoming"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1'))
|
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/'))
|
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/"))
|
||||||
|
|
||||||
self.assertTrue(af.is_allowed('https://localhost/incoming/dir1/dir2'))
|
self.assertTrue(af.is_allowed("https://localhost/incoming/dir1/dir2"))
|
||||||
self.assertTrue(af.is_allowed('https://localhost/incoming/dir1/dir2/'))
|
self.assertTrue(af.is_allowed("https://localhost/incoming/dir1/dir2/"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/incoming/dir1/dir2'))
|
self.assertFalse(af.is_allowed("http://localhost/incoming/dir1/dir2"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost/incoming/dir1/dir2/'))
|
self.assertFalse(af.is_allowed("http://localhost/incoming/dir1/dir2/"))
|
||||||
|
|
||||||
# our incoming directory we restricted
|
# our incoming directory we restricted
|
||||||
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/var'))
|
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/var"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/var/'))
|
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/var/"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/var/sub/dir'))
|
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/var/sub/dir"))
|
||||||
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/var/sub'))
|
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/var/sub"))
|
||||||
|
|
||||||
# Test the ? out
|
# Test the ? out
|
||||||
af = AppriseURLFilter('localhost?', '')
|
af = AppriseURLFilter("localhost?", "")
|
||||||
|
|
||||||
# Test garbage entries
|
# Test garbage entries
|
||||||
self.assertFalse(af.is_allowed('$'))
|
self.assertFalse(af.is_allowed("$"))
|
||||||
self.assertFalse(af.is_allowed(b'13'))
|
self.assertFalse(af.is_allowed(b"13"))
|
||||||
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
|
self.assertFalse(af.is_allowed("Mālō e Lelei"))
|
||||||
self.assertFalse(af.is_allowed(''))
|
self.assertFalse(af.is_allowed(""))
|
||||||
self.assertFalse(af.is_allowed(None))
|
self.assertFalse(af.is_allowed(None))
|
||||||
self.assertFalse(af.is_allowed(True))
|
self.assertFalse(af.is_allowed(True))
|
||||||
self.assertFalse(af.is_allowed(42))
|
self.assertFalse(af.is_allowed(42))
|
||||||
|
|
||||||
# These are blocked too since we have no allow list
|
# These are blocked too since we have no allow list
|
||||||
self.assertFalse(af.is_allowed('http://localhost'))
|
self.assertFalse(af.is_allowed("http://localhost"))
|
||||||
self.assertTrue(af.is_allowed('http://localhost1'))
|
self.assertTrue(af.is_allowed("http://localhost1"))
|
||||||
self.assertTrue(af.is_allowed('https://localhost1'))
|
self.assertTrue(af.is_allowed("https://localhost1"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost%'))
|
self.assertFalse(af.is_allowed("http://localhost%"))
|
||||||
self.assertFalse(af.is_allowed('http://localhost10'))
|
self.assertFalse(af.is_allowed("http://localhost10"))
|
||||||
|
|
||||||
# conflicting elements cancel one another
|
# conflicting elements cancel one another
|
||||||
af = AppriseURLFilter('localhost', 'localhost')
|
af = AppriseURLFilter("localhost", "localhost")
|
||||||
|
|
||||||
# These are blocked too since we have no allow list
|
# These are blocked too since we have no allow list
|
||||||
self.assertFalse(af.is_allowed('localhost'))
|
self.assertFalse(af.is_allowed("localhost"))
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -23,41 +22,41 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
import os
|
import os
|
||||||
import mock
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
from .. import utils
|
from .. import utils
|
||||||
|
|
||||||
|
|
||||||
class UtilsTests(SimpleTestCase):
|
class UtilsTests(SimpleTestCase):
|
||||||
|
|
||||||
def test_touchdir(self):
|
def test_touchdir(self):
|
||||||
"""
|
"""
|
||||||
Test touchdir()
|
Test touchdir()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
with mock.patch('os.makedirs', side_effect=OSError()):
|
with mock.patch("os.makedirs", side_effect=OSError()):
|
||||||
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False
|
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is False
|
||||||
|
|
||||||
with mock.patch('os.makedirs', side_effect=FileExistsError()):
|
with mock.patch("os.makedirs", side_effect=FileExistsError()):
|
||||||
# Dir doesn't exist
|
# Dir doesn't exist
|
||||||
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False
|
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is False
|
||||||
|
|
||||||
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is True
|
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is True
|
||||||
|
|
||||||
# Date is updated
|
# Date is updated
|
||||||
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is True
|
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is True
|
||||||
|
|
||||||
with mock.patch('os.utime', side_effect=OSError()):
|
with mock.patch("os.utime", side_effect=OSError()):
|
||||||
# Fails to update file
|
# Fails to update file
|
||||||
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False
|
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is False
|
||||||
|
|
||||||
def test_touch(self):
|
def test_touch(self):
|
||||||
"""
|
"""
|
||||||
Test touch()
|
Test touch()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir, mock.patch("os.fdopen", side_effect=OSError()):
|
||||||
with mock.patch('os.fdopen', side_effect=OSError()):
|
assert utils.touch(os.path.join(tmpdir, "tmp-file")) is False
|
||||||
assert utils.touch(os.path.join(tmpdir, 'tmp-file')) is False
|
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,17 +21,18 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.test import SimpleTestCase
|
|
||||||
from unittest import mock
|
|
||||||
from json import loads
|
from json import loads
|
||||||
import requests
|
from unittest import mock
|
||||||
from ..utils import send_webhook
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from ..utils import send_webhook
|
||||||
|
|
||||||
|
|
||||||
class WebhookTests(SimpleTestCase):
|
class WebhookTests(SimpleTestCase):
|
||||||
|
@mock.patch("requests.post")
|
||||||
@mock.patch('requests.post')
|
|
||||||
def test_webhook_testing(self, mock_post):
|
def test_webhook_testing(self, mock_post):
|
||||||
"""
|
"""
|
||||||
Test webhook handling
|
Test webhook handling
|
||||||
@@ -43,48 +43,45 @@ class WebhookTests(SimpleTestCase):
|
|||||||
response.status_code = requests.codes.ok
|
response.status_code = requests.codes.ok
|
||||||
mock_post.return_value = response
|
mock_post.return_value = response
|
||||||
|
|
||||||
with override_settings(
|
with override_settings(APPRISE_WEBHOOK_URL="https://user:pass@localhost/webhook"):
|
||||||
APPRISE_WEBHOOK_URL='https://'
|
|
||||||
'user:pass@localhost/webhook'):
|
|
||||||
send_webhook({})
|
send_webhook({})
|
||||||
assert mock_post.call_count == 1
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
details = mock_post.call_args_list[0]
|
details = mock_post.call_args_list[0]
|
||||||
assert details[0][0] == 'https://localhost/webhook'
|
assert details[0][0] == "https://localhost/webhook"
|
||||||
assert loads(details[1]['data']) == {}
|
assert loads(details[1]["data"]) == {}
|
||||||
assert 'User-Agent' in details[1]['headers']
|
assert "User-Agent" in details[1]["headers"]
|
||||||
assert 'Content-Type' in details[1]['headers']
|
assert "Content-Type" in details[1]["headers"]
|
||||||
assert details[1]['headers']['User-Agent'] == 'Apprise-API'
|
assert details[1]["headers"]["User-Agent"] == "Apprise-API"
|
||||||
assert details[1]['headers']['Content-Type'] == 'application/json'
|
assert details[1]["headers"]["Content-Type"] == "application/json"
|
||||||
assert details[1]['auth'] == ('user', 'pass')
|
assert details[1]["auth"] == ("user", "pass")
|
||||||
assert details[1]['verify'] is True
|
assert details[1]["verify"] is True
|
||||||
assert details[1]['params'] == {}
|
assert details[1]["params"] == {}
|
||||||
assert details[1]['timeout'] == (4.0, 4.0)
|
assert details[1]["timeout"] == (4.0, 4.0)
|
||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
with override_settings(
|
with override_settings(
|
||||||
APPRISE_WEBHOOK_URL='http://'
|
APPRISE_WEBHOOK_URL="http://user@localhost/webhook/here?verify=False&key=value&cto=2.0&rto=1.0"
|
||||||
'user@localhost/webhook/here'
|
):
|
||||||
'?verify=False&key=value&cto=2.0&rto=1.0'):
|
|
||||||
send_webhook({})
|
send_webhook({})
|
||||||
assert mock_post.call_count == 1
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
details = mock_post.call_args_list[0]
|
details = mock_post.call_args_list[0]
|
||||||
assert details[0][0] == 'http://localhost/webhook/here'
|
assert details[0][0] == "http://localhost/webhook/here"
|
||||||
assert loads(details[1]['data']) == {}
|
assert loads(details[1]["data"]) == {}
|
||||||
assert 'User-Agent' in details[1]['headers']
|
assert "User-Agent" in details[1]["headers"]
|
||||||
assert 'Content-Type' in details[1]['headers']
|
assert "Content-Type" in details[1]["headers"]
|
||||||
assert details[1]['headers']['User-Agent'] == 'Apprise-API'
|
assert details[1]["headers"]["User-Agent"] == "Apprise-API"
|
||||||
assert details[1]['headers']['Content-Type'] == 'application/json'
|
assert details[1]["headers"]["Content-Type"] == "application/json"
|
||||||
assert details[1]['auth'] == ('user', None)
|
assert details[1]["auth"] == ("user", None)
|
||||||
assert details[1]['verify'] is False
|
assert details[1]["verify"] is False
|
||||||
assert details[1]['params'] == {'key': 'value'}
|
assert details[1]["params"] == {"key": "value"}
|
||||||
assert details[1]['timeout'] == (2.0, 1.0)
|
assert details[1]["timeout"] == (2.0, 1.0)
|
||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
with override_settings(APPRISE_WEBHOOK_URL='invalid'):
|
with override_settings(APPRISE_WEBHOOK_URL="invalid"):
|
||||||
# Invalid webhook defined
|
# Invalid webhook defined
|
||||||
send_webhook({})
|
send_webhook({})
|
||||||
assert mock_post.call_count == 0
|
assert mock_post.call_count == 0
|
||||||
@@ -98,15 +95,14 @@ class WebhookTests(SimpleTestCase):
|
|||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
with override_settings(APPRISE_WEBHOOK_URL='http://$#@'):
|
with override_settings(APPRISE_WEBHOOK_URL="http://$#@"):
|
||||||
# Invalid hostname defined
|
# Invalid hostname defined
|
||||||
send_webhook({})
|
send_webhook({})
|
||||||
assert mock_post.call_count == 0
|
assert mock_post.call_count == 0
|
||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
with override_settings(
|
with override_settings(APPRISE_WEBHOOK_URL="invalid://hostname"):
|
||||||
APPRISE_WEBHOOK_URL='invalid://hostname'):
|
|
||||||
# Invalid webhook defined
|
# Invalid webhook defined
|
||||||
send_webhook({})
|
send_webhook({})
|
||||||
assert mock_post.call_count == 0
|
assert mock_post.call_count == 0
|
||||||
@@ -116,9 +112,7 @@ class WebhookTests(SimpleTestCase):
|
|||||||
# A valid URL with a bad server response:
|
# A valid URL with a bad server response:
|
||||||
response.status_code = requests.codes.internal_server_error
|
response.status_code = requests.codes.internal_server_error
|
||||||
mock_post.return_value = response
|
mock_post.return_value = response
|
||||||
with override_settings(
|
with override_settings(APPRISE_WEBHOOK_URL="http://localhost"):
|
||||||
APPRISE_WEBHOOK_URL='http://localhost'):
|
|
||||||
|
|
||||||
send_webhook({})
|
send_webhook({})
|
||||||
assert mock_post.call_count == 1
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
@@ -127,8 +121,6 @@ class WebhookTests(SimpleTestCase):
|
|||||||
# A valid URL with a bad server response:
|
# A valid URL with a bad server response:
|
||||||
mock_post.return_value = None
|
mock_post.return_value = None
|
||||||
mock_post.side_effect = requests.RequestException("error")
|
mock_post.side_effect = requests.RequestException("error")
|
||||||
with override_settings(
|
with override_settings(APPRISE_WEBHOOK_URL="http://localhost"):
|
||||||
APPRISE_WEBHOOK_URL='http://localhost'):
|
|
||||||
|
|
||||||
send_webhook({})
|
send_webhook({})
|
||||||
assert mock_post.call_count == 1
|
assert mock_post.call_count == 1
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -26,7 +25,6 @@ from django.test import SimpleTestCase
|
|||||||
|
|
||||||
|
|
||||||
class WelcomePageTests(SimpleTestCase):
|
class WelcomePageTests(SimpleTestCase):
|
||||||
|
|
||||||
def test_welcome_page_status_code(self):
|
def test_welcome_page_status_code(self):
|
||||||
response = self.client.get('/')
|
response = self.client.get("/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from apprise.utils.parse import parse_url
|
from apprise.utils.parse import parse_url
|
||||||
|
|
||||||
|
|
||||||
@@ -57,12 +57,12 @@ class AppriseURLFilter:
|
|||||||
"""
|
"""
|
||||||
Split the list (tokens separated by whitespace or commas) and compile each token.
|
Split the list (tokens separated by whitespace or commas) and compile each token.
|
||||||
Tokens are classified as follows:
|
Tokens are classified as follows:
|
||||||
- URL‐based tokens: if they start with “http://” or “https://” (explicit)
|
- URL-based tokens: if they start with “http://” or “https://” (explicit)
|
||||||
or if they contain a “/” (implicit; no scheme given).
|
or if they contain a “/” (implicit; no scheme given).
|
||||||
- Host‐based tokens: those that do not contain a “/”.
|
- Host-based tokens: those that do not contain a “/”.
|
||||||
Returns a list of tuples (compiled_regex, is_url_based).
|
Returns a list of tuples (compiled_regex, is_url_based).
|
||||||
"""
|
"""
|
||||||
tokens = re.split(r'[\s,]+', list_str.strip().lower())
|
tokens = re.split(r"[\s,]+", list_str.strip().lower())
|
||||||
rules = []
|
rules = []
|
||||||
for token in tokens:
|
for token in tokens:
|
||||||
if not token:
|
if not token:
|
||||||
@@ -88,7 +88,7 @@ class AppriseURLFilter:
|
|||||||
|
|
||||||
def _compile_url_token(self, token: str):
|
def _compile_url_token(self, token: str):
|
||||||
"""
|
"""
|
||||||
Compiles a URL‐based token (explicit token that starts with a scheme) into a regex.
|
Compiles a URL-based token (explicit token that starts with a scheme) into a regex.
|
||||||
An implied trailing wildcard is added to the path:
|
An implied trailing wildcard is added to the path:
|
||||||
- If no path is given (or just “/”) then “(/.*)?” is appended.
|
- If no path is given (or just “/”) then “(/.*)?” is appended.
|
||||||
- If a nonempty path is given that does not end with “/” or “*”, then “($|/.*)” is appended.
|
- If a nonempty path is given that does not end with “/” or “*”, then “($|/.*)” is appended.
|
||||||
@@ -99,18 +99,18 @@ class AppriseURLFilter:
|
|||||||
# Determine the scheme.
|
# Determine the scheme.
|
||||||
scheme_regex = ""
|
scheme_regex = ""
|
||||||
if token.startswith("http://"):
|
if token.startswith("http://"):
|
||||||
scheme_regex = r'http'
|
scheme_regex = r"http"
|
||||||
# drop http://
|
# drop http://
|
||||||
token = token[7:]
|
token = token[7:]
|
||||||
|
|
||||||
elif token.startswith("https://"):
|
elif token.startswith("https://"):
|
||||||
scheme_regex = r'https'
|
scheme_regex = r"https"
|
||||||
# drop https://
|
# drop https://
|
||||||
token = token[8:]
|
token = token[8:]
|
||||||
|
|
||||||
else: # https?
|
else: # https?
|
||||||
# Used for implicit tokens; our _compile_implicit_token ensures this.
|
# Used for implicit tokens; our _compile_implicit_token ensures this.
|
||||||
scheme_regex = r'https?'
|
scheme_regex = r"https?"
|
||||||
# strip https?://
|
# strip https?://
|
||||||
token = token[9:]
|
token = token[9:]
|
||||||
|
|
||||||
@@ -173,8 +173,8 @@ class AppriseURLFilter:
|
|||||||
|
|
||||||
def _compile_host_token(self, token: str):
|
def _compile_host_token(self, token: str):
|
||||||
"""
|
"""
|
||||||
Compiles a host‐based token (one with no “/”) into a regex.
|
Compiles a host-based token (one with no "/") into a regex.
|
||||||
Note: When matching host‐based tokens, we require that the URL’s scheme is exactly “http”.
|
Note: When matching host-based tokens, we require that the URL's scheme is exactly "http".
|
||||||
"""
|
"""
|
||||||
regex = "^" + self._wildcard_to_regex(token) + "$"
|
regex = "^" + self._wildcard_to_regex(token) + "$"
|
||||||
return re.compile(regex, re.IGNORECASE)
|
return re.compile(regex, re.IGNORECASE)
|
||||||
@@ -190,11 +190,11 @@ class AppriseURLFilter:
|
|||||||
"""
|
"""
|
||||||
regex = ""
|
regex = ""
|
||||||
for char in pattern:
|
for char in pattern:
|
||||||
if char == '*':
|
if char == "*":
|
||||||
regex += r"[^/]+/?" if not is_host else r'.*'
|
regex += r"[^/]+/?" if not is_host else r".*"
|
||||||
|
|
||||||
elif char == '?':
|
elif char == "?":
|
||||||
regex += r'[^/]' if not is_host else r"[A-Za-z0-9_-]"
|
regex += r"[^/]" if not is_host else r"[A-Za-z0-9_-]"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
regex += re.escape(char)
|
regex += re.escape(char)
|
||||||
@@ -205,14 +205,15 @@ class AppriseURLFilter:
|
|||||||
"""
|
"""
|
||||||
Checks a given URL against the deny list first, then the allow list.
|
Checks a given URL against the deny list first, then the allow list.
|
||||||
For URL-based rules (explicit or implicit), the full URL is tested.
|
For URL-based rules (explicit or implicit), the full URL is tested.
|
||||||
For host-based rules, the URL’s netloc (which includes the port) is tested.
|
For host-based rules, the URL's netloc (which includes the port) is tested.
|
||||||
"""
|
"""
|
||||||
parsed = parse_url(url, strict_port=True, simple=True)
|
parsed = parse_url(url, strict_port=True, simple=True)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# includes port if present
|
# includes port if present
|
||||||
netloc = '%s:%d' % (parsed['host'], parsed.get('port')) if parsed.get('port') else parsed['host']
|
port = parsed.get("port")
|
||||||
|
netloc = f"{parsed['host']}:{port}" if port is not None else parsed["host"]
|
||||||
|
|
||||||
# Check deny rules first.
|
# Check deny rules first.
|
||||||
for pattern, is_url_based in self.deny_rules:
|
for pattern, is_url_based in self.deny_rules:
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -23,40 +22,31 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.urls import re_path
|
from django.urls import re_path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
re_path(r"^$", views.WelcomeView.as_view(), name="welcome"),
|
||||||
|
re_path(r"^status/?$", views.HealthCheckView.as_view(), name="health"),
|
||||||
|
re_path(r"^details/?$", views.DetailsView.as_view(), name="details"),
|
||||||
re_path(
|
re_path(
|
||||||
r'^$',
|
r"^cfg/(?P<key>[\w_-]{1,128})/?$",
|
||||||
views.WelcomeView.as_view(), name='welcome'),
|
views.ConfigView.as_view(),
|
||||||
|
name="config",
|
||||||
|
),
|
||||||
|
re_path(r"^cfg/?$", views.ConfigListView.as_view(), name="config_list"),
|
||||||
|
re_path(r"^add/(?P<key>[\w_-]{1,128})/?$", views.AddView.as_view(), name="add"),
|
||||||
|
re_path(r"^del/(?P<key>[\w_-]{1,128})/?$", views.DelView.as_view(), name="del"),
|
||||||
|
re_path(r"^get/(?P<key>[\w_-]{1,128})/?$", views.GetView.as_view(), name="get"),
|
||||||
re_path(
|
re_path(
|
||||||
r'^status/?$',
|
r"^notify/(?P<key>[\w_-]{1,128})/?$",
|
||||||
views.HealthCheckView.as_view(), name='health'),
|
views.NotifyView.as_view(),
|
||||||
|
name="notify",
|
||||||
|
),
|
||||||
|
re_path(r"^notify/?$", views.StatelessNotifyView.as_view(), name="s_notify"),
|
||||||
re_path(
|
re_path(
|
||||||
r'^details/?$',
|
r"^json/urls/(?P<key>[\w_-]{1,128})/?$",
|
||||||
views.DetailsView.as_view(), name='details'),
|
views.JsonUrlView.as_view(),
|
||||||
re_path(
|
name="json_urls",
|
||||||
r'^cfg/(?P<key>[\w_-]{1,128})/?$',
|
),
|
||||||
views.ConfigView.as_view(), name='config'),
|
|
||||||
re_path(
|
|
||||||
r'^cfg/?$',
|
|
||||||
views.ConfigListView.as_view(), name='config_list'),
|
|
||||||
re_path(
|
|
||||||
r'^add/(?P<key>[\w_-]{1,128})/?$',
|
|
||||||
views.AddView.as_view(), name='add'),
|
|
||||||
re_path(
|
|
||||||
r'^del/(?P<key>[\w_-]{1,128})/?$',
|
|
||||||
views.DelView.as_view(), name='del'),
|
|
||||||
re_path(
|
|
||||||
r'^get/(?P<key>[\w_-]{1,128})/?$',
|
|
||||||
views.GetView.as_view(), name='get'),
|
|
||||||
re_path(
|
|
||||||
r'^notify/(?P<key>[\w_-]{1,128})/?$',
|
|
||||||
views.NotifyView.as_view(), name='notify'),
|
|
||||||
re_path(
|
|
||||||
r'^notify/?$',
|
|
||||||
views.StatelessNotifyView.as_view(), name='s_notify'),
|
|
||||||
re_path(
|
|
||||||
r'^json/urls/(?P<key>[\w_-]{1,128})/?$',
|
|
||||||
views.JsonUrlView.as_view(), name='json_urls'),
|
|
||||||
]
|
]
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,54 +21,60 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
import re
|
|
||||||
import binascii
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import shutil
|
|
||||||
import gzip
|
|
||||||
import apprise
|
|
||||||
import hashlib
|
|
||||||
import errno
|
|
||||||
import base64
|
import base64
|
||||||
import requests
|
import binascii
|
||||||
from json import dumps
|
from contextlib import suppress
|
||||||
from django.conf import settings
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from . urlfilter import AppriseURLFilter
|
import errno
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
from json import dumps
|
||||||
|
|
||||||
# import the logging library
|
# import the logging library
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
import requests
|
||||||
|
|
||||||
|
import apprise
|
||||||
|
|
||||||
|
from .urlfilter import AppriseURLFilter
|
||||||
|
|
||||||
# Get an instance of a logger
|
# Get an instance of a logger
|
||||||
logger = logging.getLogger('django')
|
logger = logging.getLogger("django")
|
||||||
|
|
||||||
|
|
||||||
class AppriseStoreMode(object):
|
class AppriseStoreMode:
|
||||||
"""
|
"""
|
||||||
Defines the store modes of configuration
|
Defines the store modes of configuration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# This is the default option. Content is cached and written by
|
# This is the default option. Content is cached and written by
|
||||||
# it's key
|
# it's key
|
||||||
HASH = 'hash'
|
HASH = "hash"
|
||||||
|
|
||||||
# Content is written straight to disk using it's key
|
# Content is written straight to disk using it's key
|
||||||
# there is nothing further done
|
# there is nothing further done
|
||||||
SIMPLE = 'simple'
|
SIMPLE = "simple"
|
||||||
|
|
||||||
# When set to disabled; stateful functionality is disabled
|
# When set to disabled; stateful functionality is disabled
|
||||||
DISABLED = 'disabled'
|
DISABLED = "disabled"
|
||||||
|
|
||||||
|
|
||||||
class AttachmentPayload(object):
|
class AttachmentPayload:
|
||||||
"""
|
"""
|
||||||
Defines the supported Attachment Payload Types
|
Defines the supported Attachment Payload Types
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# BASE64
|
# BASE64
|
||||||
BASE64 = 'base64'
|
BASE64 = "base64"
|
||||||
|
|
||||||
# URL request
|
# URL request
|
||||||
URL = 'url'
|
URL = "url"
|
||||||
|
|
||||||
|
|
||||||
STORE_MODES = (
|
STORE_MODES = (
|
||||||
@@ -88,7 +93,7 @@ N_MGR = apprise.manager_plugins.NotificationManager()
|
|||||||
ATTACH_URL_FILTER = AppriseURLFilter(settings.APPRISE_ATTACH_ALLOW_URLS, settings.APPRISE_ATTACH_DENY_URLS)
|
ATTACH_URL_FILTER = AppriseURLFilter(settings.APPRISE_ATTACH_ALLOW_URLS, settings.APPRISE_ATTACH_DENY_URLS)
|
||||||
|
|
||||||
|
|
||||||
class Attachment(A_MGR['file']):
|
class Attachment(A_MGR["file"]):
|
||||||
"""
|
"""
|
||||||
A Light Weight Attachment Object for Auto-cleanup that wraps the Apprise
|
A Light Weight Attachment Object for Auto-cleanup that wraps the Apprise
|
||||||
Attachments
|
Attachments
|
||||||
@@ -106,8 +111,7 @@ class Attachment(A_MGR['file']):
|
|||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
# Permission error
|
# Permission error
|
||||||
raise ValueError('Could not create directory {}'.format(
|
raise ValueError("Could not create directory {}".format(settings.APPRISE_ATTACH_DIR)) from None
|
||||||
settings.APPRISE_ATTACH_DIR))
|
|
||||||
|
|
||||||
if not path:
|
if not path:
|
||||||
try:
|
try:
|
||||||
@@ -117,8 +121,8 @@ class Attachment(A_MGR['file']):
|
|||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'Could not prepare {} attachment in {}'.format(
|
"Could not prepare {} attachment in {}".format(filename, settings.APPRISE_ATTACH_DIR)
|
||||||
filename, settings.APPRISE_ATTACH_DIR))
|
) from None
|
||||||
|
|
||||||
self._path = path
|
self._path = path
|
||||||
|
|
||||||
@@ -144,14 +148,12 @@ class Attachment(A_MGR['file']):
|
|||||||
De-Construtor is used to tidy up files during garbage collection
|
De-Construtor is used to tidy up files during garbage collection
|
||||||
"""
|
"""
|
||||||
if self.delete and self._path:
|
if self.delete and self._path:
|
||||||
try:
|
# no problem if file is missing
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
os.remove(self._path)
|
os.remove(self._path)
|
||||||
except FileNotFoundError:
|
|
||||||
# no problem
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPAttachment(A_MGR['http']):
|
class HTTPAttachment(A_MGR["http"]):
|
||||||
"""
|
"""
|
||||||
A Light Weight Attachment Object for Auto-cleanup that wraps the Apprise
|
A Light Weight Attachment Object for Auto-cleanup that wraps the Apprise
|
||||||
Web Attachments
|
Web Attachments
|
||||||
@@ -169,8 +171,7 @@ class HTTPAttachment(A_MGR['http']):
|
|||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
# Permission error
|
# Permission error
|
||||||
raise ValueError('Could not create directory {}'.format(
|
raise ValueError("Could not create directory {}".format(settings.APPRISE_ATTACH_DIR)) from None
|
||||||
settings.APPRISE_ATTACH_DIR))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
d, self._path = tempfile.mkstemp(dir=settings.APPRISE_ATTACH_DIR)
|
d, self._path = tempfile.mkstemp(dir=settings.APPRISE_ATTACH_DIR)
|
||||||
@@ -179,8 +180,8 @@ class HTTPAttachment(A_MGR['http']):
|
|||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'Could not prepare {} attachment in {}'.format(
|
"Could not prepare {} attachment in {}".format(filename, settings.APPRISE_ATTACH_DIR)
|
||||||
filename, settings.APPRISE_ATTACH_DIR))
|
) from None
|
||||||
|
|
||||||
# Prepare our item
|
# Prepare our item
|
||||||
super().__init__(name=filename, **kwargs)
|
super().__init__(name=filename, **kwargs)
|
||||||
@@ -204,11 +205,9 @@ class HTTPAttachment(A_MGR['http']):
|
|||||||
De-Construtor is used to tidy up files during garbage collection
|
De-Construtor is used to tidy up files during garbage collection
|
||||||
"""
|
"""
|
||||||
if self.delete and self._path:
|
if self.delete and self._path:
|
||||||
try:
|
# no problem if file is missing
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
os.remove(self._path)
|
os.remove(self._path)
|
||||||
except FileNotFoundError:
|
|
||||||
# no problem
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def touchdir(path, mode=0o770, **kwargs):
|
def touchdir(path, mode=0o770, **kwargs):
|
||||||
@@ -238,8 +237,11 @@ def touch(fname, mode=0o666, dir_fd=None, **kwargs):
|
|||||||
flags = os.O_CREAT | os.O_APPEND
|
flags = os.O_CREAT | os.O_APPEND
|
||||||
try:
|
try:
|
||||||
with os.fdopen(os.open(fname, flags=flags, mode=mode, dir_fd=dir_fd)) as f:
|
with os.fdopen(os.open(fname, flags=flags, mode=mode, dir_fd=dir_fd)) as f:
|
||||||
os.utime(f.fileno() if os.utime in os.supports_fd else fname,
|
os.utime(
|
||||||
dir_fd=None if os.supports_fd else dir_fd, **kwargs)
|
f.fileno() if os.utime in os.supports_fd else fname,
|
||||||
|
dir_fd=None if os.supports_fd else dir_fd,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
@@ -266,26 +268,25 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
raise ValueError("Attachment support has been disabled")
|
raise ValueError("Attachment support has been disabled")
|
||||||
|
|
||||||
# Attachment Count
|
# Attachment Count
|
||||||
count = sum([
|
count = sum(
|
||||||
0 if not isinstance(attachment_payload, (set, tuple, list))
|
[
|
||||||
else len(attachment_payload),
|
(0 if not isinstance(attachment_payload, set | tuple | list) else len(attachment_payload)),
|
||||||
0 if not isinstance(files_request, dict) else len(files_request),
|
0 if not isinstance(files_request, dict) else len(files_request),
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
if isinstance(attachment_payload, (dict, str, bytes)):
|
if isinstance(attachment_payload, dict | str | bytes):
|
||||||
# Convert and adjust counter
|
# Convert and adjust counter
|
||||||
attachment_payload = (attachment_payload, )
|
attachment_payload = (attachment_payload,)
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
if settings.APPRISE_MAX_ATTACHMENTS > 0 and count > settings.APPRISE_MAX_ATTACHMENTS:
|
if settings.APPRISE_MAX_ATTACHMENTS > 0 and count > settings.APPRISE_MAX_ATTACHMENTS:
|
||||||
raise ValueError(
|
raise ValueError(f"There is a maximum of {settings.APPRISE_MAX_ATTACHMENTS} attachments")
|
||||||
"There is a maximum of %d attachments" %
|
|
||||||
settings.APPRISE_MAX_ATTACHMENTS)
|
|
||||||
|
|
||||||
if isinstance(attachment_payload, (tuple, list, set)):
|
if isinstance(attachment_payload, tuple | list | set):
|
||||||
for no, entry in enumerate(attachment_payload, start=1):
|
for no, entry in enumerate(attachment_payload, start=1):
|
||||||
if isinstance(entry, (str, bytes)):
|
if isinstance(entry, str | bytes):
|
||||||
filename = "attachment.%.3d" % no
|
filename = f"attachment.{no:03d}"
|
||||||
|
|
||||||
elif isinstance(entry, dict):
|
elif isinstance(entry, dict):
|
||||||
try:
|
try:
|
||||||
@@ -293,24 +294,19 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
|
|
||||||
# Max filename size is 250
|
# Max filename size is 250
|
||||||
if len(filename) > 250:
|
if len(filename) > 250:
|
||||||
raise ValueError(
|
raise ValueError(f"The filename associated with attachment {no} is too long")
|
||||||
"The filename associated with attachment "
|
|
||||||
"%d is too long" % no)
|
|
||||||
|
|
||||||
elif not filename:
|
elif not filename:
|
||||||
filename = "attachment.%.3d" % no
|
filename = f"attachment.{no:03d}"
|
||||||
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# not a string that was provided
|
# not a string that was provided
|
||||||
raise ValueError(
|
raise ValueError(f"An invalid filename was provided for attachment {no}") from None
|
||||||
"An invalid filename was provided for attachment %d" %
|
|
||||||
no)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# you must pass in a base64 string, or a dict containing our
|
# you must pass in a base64 string, or a dict containing our
|
||||||
# required parameters
|
# required parameters
|
||||||
raise ValueError(
|
raise ValueError(f"An invalid filename was provided for attachment {no}")
|
||||||
"An invalid filename was provided for attachment %d" % no)
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Prepare our Attachment
|
# Prepare our Attachment
|
||||||
@@ -324,82 +320,62 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
count -= 1
|
count -= 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not re.match(r'^https?://.+', entry[:10], re.I):
|
if not re.match(r"^https?://.+", entry[:10], re.I):
|
||||||
# We failed to retrieve the product
|
# We failed to retrieve the product
|
||||||
raise ValueError(
|
raise ValueError(f"Failed to load attachment {no} (not web request): {entry}")
|
||||||
"Failed to load attachment "
|
|
||||||
"%d (not web request): %s" % (no, entry))
|
|
||||||
|
|
||||||
if not ATTACH_URL_FILTER.is_allowed(entry):
|
if not ATTACH_URL_FILTER.is_allowed(entry):
|
||||||
# We are not allowed to use this entry
|
# We are not allowed to use this entry
|
||||||
raise ValueError(
|
raise ValueError(f"Denied attachment {no} (blocked web request): {entry}")
|
||||||
"Denied attachment "
|
|
||||||
"%d (blocked web request): %s" % (no, entry))
|
|
||||||
|
|
||||||
attachment = HTTPAttachment(
|
attachment = HTTPAttachment(filename, **A_MGR["http"].parse_url(entry))
|
||||||
filename, **A_MGR['http'].parse_url(entry))
|
|
||||||
if not attachment:
|
if not attachment:
|
||||||
# We failed to retrieve the attachment
|
# We failed to retrieve the attachment
|
||||||
raise ValueError(
|
raise ValueError(f"Failed to retrieve attachment {no}: {entry}")
|
||||||
"Failed to retrieve attachment %d: %s" % (no, entry))
|
|
||||||
|
|
||||||
else: # web, base64 or raw
|
else: # web, base64 or raw
|
||||||
attachment = Attachment(filename)
|
attachment = Attachment(filename)
|
||||||
try:
|
try:
|
||||||
with open(attachment.path, 'wb') as f:
|
with open(attachment.path, "wb") as f:
|
||||||
# Write our content to disk
|
# Write our content to disk
|
||||||
if isinstance(entry, dict) and \
|
if isinstance(entry, dict) and AttachmentPayload.BASE64 in entry:
|
||||||
AttachmentPayload.BASE64 in entry:
|
|
||||||
# BASE64
|
# BASE64
|
||||||
f.write(
|
f.write(base64.b64decode(entry[AttachmentPayload.BASE64]))
|
||||||
base64.b64decode(
|
|
||||||
entry[AttachmentPayload.BASE64]))
|
|
||||||
|
|
||||||
elif isinstance(entry, dict) and \
|
|
||||||
AttachmentPayload.URL in entry:
|
|
||||||
|
|
||||||
|
elif isinstance(entry, dict) and AttachmentPayload.URL in entry:
|
||||||
if not ATTACH_URL_FILTER.is_allowed(entry[AttachmentPayload.URL]):
|
if not ATTACH_URL_FILTER.is_allowed(entry[AttachmentPayload.URL]):
|
||||||
# We are not allowed to use this entry
|
# We are not allowed to use this entry
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Denied attachment "
|
f"Denied attachment {no} (blocked web request): {entry[AttachmentPayload.URL]}"
|
||||||
"%d (blocked web request): %s" % (
|
)
|
||||||
no, entry[AttachmentPayload.URL]))
|
|
||||||
|
|
||||||
attachment = HTTPAttachment(
|
attachment = HTTPAttachment(
|
||||||
filename, **A_MGR['http']
|
filename,
|
||||||
.parse_url(entry[AttachmentPayload.URL]))
|
**A_MGR["http"].parse_url(entry[AttachmentPayload.URL]),
|
||||||
|
)
|
||||||
if not attachment:
|
if not attachment:
|
||||||
# We failed to retrieve the attachment
|
# We failed to retrieve the attachment
|
||||||
raise ValueError(
|
raise ValueError(f"Failed to retrieve attachment {no}: {entry}")
|
||||||
"Failed to retrieve attachment "
|
|
||||||
"%d: %s" % (no, entry))
|
|
||||||
|
|
||||||
elif isinstance(entry, bytes):
|
elif isinstance(entry, bytes):
|
||||||
# RAW
|
# RAW
|
||||||
f.write(entry)
|
f.write(entry)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(f"Invalid filetype was provided for attachment {filename}")
|
||||||
"Invalid filetype was provided for "
|
|
||||||
"attachment %s" % filename)
|
|
||||||
|
|
||||||
except binascii.Error:
|
except binascii.Error:
|
||||||
# The file ws not base64 encoded
|
# The file ws not base64 encoded
|
||||||
raise ValueError(
|
raise ValueError(f"Invalid filecontent was provided for attachment {filename}") from None
|
||||||
"Invalid filecontent was provided for attachment %s" %
|
|
||||||
filename)
|
|
||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
raise ValueError(
|
raise ValueError(f"Could not write attachment {filename} to disk") from None
|
||||||
"Could not write attachment %s to disk" % filename)
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Some Validation
|
# Some Validation
|
||||||
#
|
#
|
||||||
if settings.APPRISE_ATTACH_SIZE > 0 and \
|
if settings.APPRISE_ATTACH_SIZE > 0 and attachment.size > settings.APPRISE_ATTACH_SIZE:
|
||||||
attachment.size > settings.APPRISE_ATTACH_SIZE:
|
raise ValueError(f"attachment {filename}'s filesize is to large")
|
||||||
raise ValueError(
|
|
||||||
"attachment %s's filesize is to large" % filename)
|
|
||||||
|
|
||||||
# Add our attachment
|
# Add our attachment
|
||||||
attachments.append(attachment)
|
attachments.append(attachment)
|
||||||
@@ -408,9 +384,7 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
# Now handle the request.FILES
|
# Now handle the request.FILES
|
||||||
#
|
#
|
||||||
if isinstance(files_request, dict):
|
if isinstance(files_request, dict):
|
||||||
for no, (key, meta) in enumerate(
|
for no, (_key, meta) in enumerate(files_request.items(), start=len(attachments) + 1):
|
||||||
files_request.items(), start=len(attachments) + 1):
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Filetype is presumed to be of base class
|
# Filetype is presumed to be of base class
|
||||||
# django.core.files.UploadedFile
|
# django.core.files.UploadedFile
|
||||||
@@ -418,37 +392,31 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
|
|
||||||
# Max filename size is 250
|
# Max filename size is 250
|
||||||
if len(filename) > 250:
|
if len(filename) > 250:
|
||||||
raise ValueError(
|
raise ValueError(f"The filename associated with attachment {no} is too long")
|
||||||
"The filename associated with attachment "
|
|
||||||
"%d is too long" % no)
|
|
||||||
|
|
||||||
elif not filename:
|
elif not filename:
|
||||||
filename = "attachment.%.3d" % no
|
filename = f"attachment.{no:03d}"
|
||||||
|
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
raise ValueError(
|
raise ValueError(f"An invalid filename was provided for attachment {no}") from None
|
||||||
"An invalid filename was provided for attachment %d" % no)
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Prepare our Attachment
|
# Prepare our Attachment
|
||||||
#
|
#
|
||||||
attachment = Attachment(filename)
|
attachment = Attachment(filename)
|
||||||
try:
|
try:
|
||||||
with open(attachment.path, 'wb') as f:
|
with open(attachment.path, "wb") as f:
|
||||||
# Write our content to disk
|
# Write our content to disk
|
||||||
f.write(meta.read())
|
f.write(meta.read())
|
||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
raise ValueError(
|
raise ValueError(f"Could not write attachment {filename} to disk") from None
|
||||||
"Could not write attachment %s to disk" % filename)
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Some Validation
|
# Some Validation
|
||||||
#
|
#
|
||||||
if settings.APPRISE_ATTACH_SIZE > 0 and \
|
if settings.APPRISE_ATTACH_SIZE > 0 and attachment.size > settings.APPRISE_ATTACH_SIZE:
|
||||||
attachment.size > settings.APPRISE_ATTACH_SIZE:
|
raise ValueError(f"attachment {filename}'s filesize is to large")
|
||||||
raise ValueError(
|
|
||||||
"attachment %s's filesize is to large" % filename)
|
|
||||||
|
|
||||||
# Add our attachment
|
# Add our attachment
|
||||||
attachments.append(attachment)
|
attachments.append(attachment)
|
||||||
@@ -456,20 +424,21 @@ def parse_attachments(attachment_payload, files_request):
|
|||||||
return attachments
|
return attachments
|
||||||
|
|
||||||
|
|
||||||
class SimpleFileExtension(object):
|
class SimpleFileExtension:
|
||||||
"""
|
"""
|
||||||
Defines the simple file exension lookups
|
Defines the simple file exension lookups
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Simple Configuration file
|
# Simple Configuration file
|
||||||
TEXT = 'cfg'
|
TEXT = "cfg"
|
||||||
|
|
||||||
# YAML Configuration file
|
# YAML Configuration file
|
||||||
YAML = 'yml'
|
YAML = "yml"
|
||||||
|
|
||||||
|
|
||||||
SIMPLE_FILE_EXTENSION_MAPPING = {
|
SIMPLE_FILE_EXTENSION_MAPPING = {
|
||||||
apprise.ConfigFormat.TEXT: SimpleFileExtension.TEXT,
|
apprise.ConfigFormat.TEXT.value: SimpleFileExtension.TEXT,
|
||||||
apprise.ConfigFormat.YAML: SimpleFileExtension.YAML,
|
apprise.ConfigFormat.YAML.value: SimpleFileExtension.YAML,
|
||||||
SimpleFileExtension.TEXT: SimpleFileExtension.TEXT,
|
SimpleFileExtension.TEXT: SimpleFileExtension.TEXT,
|
||||||
SimpleFileExtension.YAML: SimpleFileExtension.YAML,
|
SimpleFileExtension.YAML: SimpleFileExtension.YAML,
|
||||||
}
|
}
|
||||||
@@ -477,7 +446,7 @@ SIMPLE_FILE_EXTENSION_MAPPING = {
|
|||||||
SIMPLE_FILE_EXTENSIONS = (SimpleFileExtension.TEXT, SimpleFileExtension.YAML)
|
SIMPLE_FILE_EXTENSIONS = (SimpleFileExtension.TEXT, SimpleFileExtension.YAML)
|
||||||
|
|
||||||
|
|
||||||
class AppriseConfigCache(object):
|
class AppriseConfigCache:
|
||||||
"""
|
"""
|
||||||
Designed to make it easy to store/read contact back from disk in a cache
|
Designed to make it easy to store/read contact back from disk in a cache
|
||||||
type structure that is fast.
|
type structure that is fast.
|
||||||
@@ -492,9 +461,7 @@ class AppriseConfigCache(object):
|
|||||||
self.mode = mode.strip().lower()
|
self.mode = mode.strip().lower()
|
||||||
if self.mode not in STORE_MODES:
|
if self.mode not in STORE_MODES:
|
||||||
self.mode = AppriseStoreMode.DISABLED
|
self.mode = AppriseStoreMode.DISABLED
|
||||||
logger.error(
|
logger.error("APPRISE_STATEFUL_MODE {} is not supported; reverted to {}.".format(mode, self.mode))
|
||||||
'APPRISE_STATEFUL_MODE {} is not supported; '
|
|
||||||
'reverted to {}.'.format(mode, self.mode))
|
|
||||||
|
|
||||||
def put(self, key, content, fmt):
|
def put(self, key, content, fmt):
|
||||||
"""
|
"""
|
||||||
@@ -519,18 +486,18 @@ class AppriseConfigCache(object):
|
|||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
# Permission error
|
# Permission error
|
||||||
logger.error('Could not create directory {}'.format(path))
|
logger.error("Could not create directory {}".format(path))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Write our file to a temporary file
|
# Write our file to a temporary file
|
||||||
d, tmp_path = tempfile.mkstemp(suffix='.tmp', dir=path)
|
d, tmp_path = tempfile.mkstemp(suffix=".tmp", dir=path)
|
||||||
# Close the file handle provided by mkstemp()
|
# Close the file handle provided by mkstemp()
|
||||||
# We're reopening it, and it can't be renamed while open on Windows
|
# We're reopening it, and it can't be renamed while open on Windows
|
||||||
os.close(d)
|
os.close(d)
|
||||||
|
|
||||||
if self.mode == AppriseStoreMode.HASH:
|
if self.mode == AppriseStoreMode.HASH:
|
||||||
try:
|
try:
|
||||||
with gzip.open(tmp_path, 'wb') as f:
|
with gzip.open(tmp_path, "wb") as f:
|
||||||
# Write our content to disk
|
# Write our content to disk
|
||||||
f.write(content.encode())
|
f.write(content.encode())
|
||||||
|
|
||||||
@@ -543,7 +510,7 @@ class AppriseConfigCache(object):
|
|||||||
# Update our file extenion based on our fmt
|
# Update our file extenion based on our fmt
|
||||||
fmt = SIMPLE_FILE_EXTENSION_MAPPING[fmt]
|
fmt = SIMPLE_FILE_EXTENSION_MAPPING[fmt]
|
||||||
try:
|
try:
|
||||||
with open(tmp_path, 'wb') as f:
|
with open(tmp_path, "wb") as f:
|
||||||
# Write our content to disk
|
# Write our content to disk
|
||||||
f.write(content.encode())
|
f.write(content.encode())
|
||||||
|
|
||||||
@@ -555,8 +522,7 @@ class AppriseConfigCache(object):
|
|||||||
# If we reach here we successfully wrote the content. We now safely
|
# If we reach here we successfully wrote the content. We now safely
|
||||||
# move our configuration into place. The following writes our content
|
# move our configuration into place. The following writes our content
|
||||||
# to disk
|
# to disk
|
||||||
shutil.move(tmp_path, os.path.join(
|
shutil.move(tmp_path, os.path.join(path, "{}.{}".format(filename, fmt)))
|
||||||
path, '{}.{}'.format(filename, fmt)))
|
|
||||||
|
|
||||||
# perform tidy of any other lingering files of other type in case
|
# perform tidy of any other lingering files of other type in case
|
||||||
# configuration changed from TEXT -> YAML or YAML -> TEXT
|
# configuration changed from TEXT -> YAML or YAML -> TEXT
|
||||||
@@ -593,7 +559,7 @@ class AppriseConfigCache(object):
|
|||||||
|
|
||||||
if self.mode == AppriseStoreMode.DISABLED:
|
if self.mode == AppriseStoreMode.DISABLED:
|
||||||
# Do nothing
|
# Do nothing
|
||||||
return (None, '')
|
return (None, "")
|
||||||
|
|
||||||
# There isn't a lot of error handling done here as it is presumed most
|
# There isn't a lot of error handling done here as it is presumed most
|
||||||
# of the checking has been done higher up.
|
# of the checking has been done higher up.
|
||||||
@@ -606,36 +572,32 @@ class AppriseConfigCache(object):
|
|||||||
|
|
||||||
# Test the only possible hashed files we expect to find
|
# Test the only possible hashed files we expect to find
|
||||||
if self.mode == AppriseStoreMode.HASH:
|
if self.mode == AppriseStoreMode.HASH:
|
||||||
text_file = os.path.join(
|
text_file = os.path.join(path, "{}.{}".format(filename, apprise.ConfigFormat.TEXT.value))
|
||||||
path, '{}.{}'.format(filename, apprise.ConfigFormat.TEXT))
|
yaml_file = os.path.join(path, "{}.{}".format(filename, apprise.ConfigFormat.YAML.value))
|
||||||
yaml_file = os.path.join(
|
|
||||||
path, '{}.{}'.format(filename, apprise.ConfigFormat.YAML))
|
|
||||||
|
|
||||||
else: # AppriseStoreMode.SIMPLE
|
else: # AppriseStoreMode.SIMPLE
|
||||||
text_file = os.path.join(
|
text_file = os.path.join(path, "{}.{}".format(filename, SimpleFileExtension.TEXT))
|
||||||
path, '{}.{}'.format(filename, SimpleFileExtension.TEXT))
|
yaml_file = os.path.join(path, "{}.{}".format(filename, SimpleFileExtension.YAML))
|
||||||
yaml_file = os.path.join(
|
|
||||||
path, '{}.{}'.format(filename, SimpleFileExtension.YAML))
|
|
||||||
|
|
||||||
if os.path.isfile(text_file):
|
if os.path.isfile(text_file):
|
||||||
fmt = apprise.ConfigFormat.TEXT
|
fmt = apprise.ConfigFormat.TEXT.value
|
||||||
path = text_file
|
path = text_file
|
||||||
|
|
||||||
elif os.path.isfile(yaml_file):
|
elif os.path.isfile(yaml_file):
|
||||||
fmt = apprise.ConfigFormat.YAML
|
fmt = apprise.ConfigFormat.YAML.value
|
||||||
path = yaml_file
|
path = yaml_file
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Not found; we set the fmt to something other than none as
|
# Not found; we set the fmt to something other than none as
|
||||||
# an indication for the upstream handling to know that we didn't
|
# an indication for the upstream handling to know that we didn't
|
||||||
# fail on error
|
# fail on error
|
||||||
return (None, '')
|
return (None, "")
|
||||||
|
|
||||||
# Initialize our content
|
# Initialize our content
|
||||||
content = None
|
content = None
|
||||||
if self.mode == AppriseStoreMode.HASH:
|
if self.mode == AppriseStoreMode.HASH:
|
||||||
try:
|
try:
|
||||||
with gzip.open(path, 'rb') as f:
|
with gzip.open(path, "rb") as f:
|
||||||
# Write our content to disk
|
# Write our content to disk
|
||||||
content = f.read().decode()
|
content = f.read().decode()
|
||||||
|
|
||||||
@@ -646,7 +608,7 @@ class AppriseConfigCache(object):
|
|||||||
|
|
||||||
else: # AppriseStoreMode.SIMPLE
|
else: # AppriseStoreMode.SIMPLE
|
||||||
try:
|
try:
|
||||||
with open(path, 'rb') as f:
|
with open(path, "rb") as f:
|
||||||
# Write our content to disk
|
# Write our content to disk
|
||||||
content = f.read().decode()
|
content = f.read().decode()
|
||||||
|
|
||||||
@@ -683,10 +645,15 @@ class AppriseConfigCache(object):
|
|||||||
# Eliminate any existing content if present
|
# Eliminate any existing content if present
|
||||||
try:
|
try:
|
||||||
# Handle failure
|
# Handle failure
|
||||||
os.remove(os.path.join(path, '{}.{}'.format(
|
os.remove(
|
||||||
filename,
|
os.path.join(
|
||||||
fmt if self.mode == AppriseStoreMode.HASH
|
path,
|
||||||
else SIMPLE_FILE_EXTENSION_MAPPING[fmt])))
|
"{}.{}".format(
|
||||||
|
filename,
|
||||||
|
(fmt if self.mode == AppriseStoreMode.HASH else SIMPLE_FILE_EXTENSION_MAPPING[fmt]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# If we reach here, an element was removed
|
# If we reach here, an element was removed
|
||||||
response = True
|
response = True
|
||||||
@@ -708,7 +675,7 @@ class AppriseConfigCache(object):
|
|||||||
path = os.path.join(self.root, encoded_key[0:2])
|
path = os.path.join(self.root, encoded_key[0:2])
|
||||||
return (path, encoded_key[2:])
|
return (path, encoded_key[2:])
|
||||||
|
|
||||||
else: # AppriseStoreMode.SIMPLE
|
else: # AppriseStoreMode.SIMPLE
|
||||||
return (self.root, key)
|
return (self.root, key)
|
||||||
|
|
||||||
def keys(self):
|
def keys(self):
|
||||||
@@ -720,7 +687,7 @@ class AppriseConfigCache(object):
|
|||||||
return keys
|
return keys
|
||||||
|
|
||||||
for filename in sorted(os.listdir(self.root)):
|
for filename in sorted(os.listdir(self.root)):
|
||||||
if filename.startswith('.'):
|
if filename.startswith("."):
|
||||||
continue
|
continue
|
||||||
path = os.path.join(self.root, filename)
|
path = os.path.join(self.root, filename)
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
@@ -732,8 +699,10 @@ class AppriseConfigCache(object):
|
|||||||
|
|
||||||
# Initialize our singleton
|
# Initialize our singleton
|
||||||
ConfigCache = AppriseConfigCache(
|
ConfigCache = AppriseConfigCache(
|
||||||
settings.APPRISE_CONFIG_DIR, salt=settings.SECRET_KEY,
|
settings.APPRISE_CONFIG_DIR,
|
||||||
mode=settings.APPRISE_STATEFUL_MODE)
|
salt=settings.SECRET_KEY,
|
||||||
|
mode=settings.APPRISE_STATEFUL_MODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_global_filters():
|
def apply_global_filters():
|
||||||
@@ -741,22 +710,22 @@ def apply_global_filters():
|
|||||||
# Apply Any Global Filters (if identified)
|
# Apply Any Global Filters (if identified)
|
||||||
#
|
#
|
||||||
if settings.APPRISE_ALLOW_SERVICES:
|
if settings.APPRISE_ALLOW_SERVICES:
|
||||||
alphanum_re = re.compile(
|
alphanum_re = re.compile(r"^(?P<name>[a-z][a-z0-9]+)", re.IGNORECASE)
|
||||||
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
|
entries = [
|
||||||
entries = \
|
alphanum_re.match(x).group("name").lower()
|
||||||
[alphanum_re.match(x).group('name').lower()
|
for x in re.split(r"[ ,]+", settings.APPRISE_ALLOW_SERVICES)
|
||||||
for x in re.split(r'[ ,]+', settings.APPRISE_ALLOW_SERVICES)
|
if alphanum_re.match(x)
|
||||||
if alphanum_re.match(x)]
|
]
|
||||||
|
|
||||||
N_MGR.enable_only(*entries)
|
N_MGR.enable_only(*entries)
|
||||||
|
|
||||||
elif settings.APPRISE_DENY_SERVICES:
|
elif settings.APPRISE_DENY_SERVICES:
|
||||||
alphanum_re = re.compile(
|
alphanum_re = re.compile(r"^(?P<name>[a-z][a-z0-9]+)", re.IGNORECASE)
|
||||||
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
|
entries = [
|
||||||
entries = \
|
alphanum_re.match(x).group("name").lower()
|
||||||
[alphanum_re.match(x).group('name').lower()
|
for x in re.split(r"[ ,]+", settings.APPRISE_DENY_SERVICES)
|
||||||
for x in re.split(r'[ ,]+', settings.APPRISE_DENY_SERVICES)
|
if alphanum_re.match(x)
|
||||||
if alphanum_re.match(x)]
|
]
|
||||||
|
|
||||||
N_MGR.disable(*entries)
|
N_MGR.disable(*entries)
|
||||||
|
|
||||||
@@ -767,8 +736,8 @@ def gen_unique_config_id():
|
|||||||
"""
|
"""
|
||||||
# our key to use
|
# our key to use
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
h.update(datetime.now().strftime('%Y%m%d%H%M%S%f').encode('utf-8'))
|
h.update(datetime.now().strftime("%Y%m%d%H%M%S%f").encode("utf-8"))
|
||||||
h.update(settings.SECRET_KEY.encode('utf-8'))
|
h.update(settings.SECRET_KEY.encode("utf-8"))
|
||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
@@ -779,28 +748,26 @@ def send_webhook(payload):
|
|||||||
|
|
||||||
# Prepare HTTP Headers
|
# Prepare HTTP Headers
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': 'Apprise-API',
|
"User-Agent": "Apprise-API",
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not apprise.utils.parse.VALID_URL_RE.match(settings.APPRISE_WEBHOOK_URL).group('schema'):
|
if not apprise.utils.parse.VALID_URL_RE.match(settings.APPRISE_WEBHOOK_URL).group("schema"):
|
||||||
raise AttributeError()
|
raise AttributeError()
|
||||||
|
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
logger.warning(
|
logger.warning("The Apprise Webhook Result URL is not a valid web based URI")
|
||||||
'The Apprise Webhook Result URL is not a valid web based URI')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse our URL
|
# Parse our URL
|
||||||
results = apprise.URLBase.parse_url(settings.APPRISE_WEBHOOK_URL)
|
results = apprise.URLBase.parse_url(settings.APPRISE_WEBHOOK_URL)
|
||||||
if not results:
|
if not results:
|
||||||
logger.warning('The Apprise Webhook Result URL is not parseable')
|
logger.warning("The Apprise Webhook Result URL is not parseable")
|
||||||
return
|
return
|
||||||
|
|
||||||
if results['schema'] not in ('http', 'https'):
|
if results["schema"] not in ("http", "https"):
|
||||||
logger.warning(
|
logger.warning("The Apprise Webhook Result URL is not using the HTTP protocol")
|
||||||
'The Apprise Webhook Result URL is not using the HTTP protocol')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Load our URL
|
# Load our URL
|
||||||
@@ -808,8 +775,7 @@ def send_webhook(payload):
|
|||||||
|
|
||||||
# Our Query String Dictionary; we use this to track arguments
|
# Our Query String Dictionary; we use this to track arguments
|
||||||
# specified that aren't otherwise part of this class
|
# specified that aren't otherwise part of this class
|
||||||
params = {k: v for k, v in results.get('qsd', {}).items()
|
params = {k: v for k, v in results.get("qsd", {}).items() if k not in base.template_args}
|
||||||
if k not in base.template_args}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
requests.post(
|
requests.post(
|
||||||
@@ -823,10 +789,8 @@ def send_webhook(payload):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.warning(
|
logger.warning("A Connection error occurred sending the Apprise Webhook results to %s.", base.url(privacy=True))
|
||||||
'A Connection error occurred sending the Apprise Webhook '
|
logger.debug("Socket Exception: %s", str(e))
|
||||||
'results to %s.' % base.url(privacy=True))
|
|
||||||
logger.debug('Socket Exception: %s' % str(e))
|
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -838,21 +802,21 @@ def healthcheck(lazy=True):
|
|||||||
|
|
||||||
# Some status variables we can flip
|
# Some status variables we can flip
|
||||||
response = {
|
response = {
|
||||||
'persistent_storage': False,
|
"persistent_storage": False,
|
||||||
'can_write_config': False,
|
"can_write_config": False,
|
||||||
'can_write_attach': False,
|
"can_write_attach": False,
|
||||||
'details': [],
|
"details": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
if not (settings.APPRISE_STATEFUL_MODE == AppriseStoreMode.DISABLED or settings.APPRISE_CONFIG_LOCK):
|
if not (settings.APPRISE_STATEFUL_MODE == AppriseStoreMode.DISABLED or settings.APPRISE_CONFIG_LOCK):
|
||||||
# Update our Configuration Check Block
|
# Update our Configuration Check Block
|
||||||
path = os.path.join(ConfigCache.root, '.tmp_hc')
|
path = os.path.join(ConfigCache.root, ".tmp_hc")
|
||||||
if lazy:
|
if lazy:
|
||||||
try:
|
try:
|
||||||
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
|
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
|
||||||
delta = (datetime.now() - modify_date).total_seconds()
|
delta = (datetime.now() - modify_date).total_seconds()
|
||||||
if delta <= 30.00: # 30s
|
if delta <= 30.00: # 30s
|
||||||
response['can_write_config'] = True
|
response["can_write_config"] = True
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# No worries... continue with below testing
|
# No worries... continue with below testing
|
||||||
@@ -861,34 +825,34 @@ def healthcheck(lazy=True):
|
|||||||
except OSError:
|
except OSError:
|
||||||
# Permission Issue or something else likely
|
# Permission Issue or something else likely
|
||||||
# We can take an early exit
|
# We can take an early exit
|
||||||
response['details'].append('CONFIG_PERMISSION_ISSUE')
|
response["details"].append("CONFIG_PERMISSION_ISSUE")
|
||||||
|
|
||||||
if not (response['can_write_config'] or 'CONFIG_PERMISSION_ISSUE' in response['details']):
|
if not (response["can_write_config"] or "CONFIG_PERMISSION_ISSUE" in response["details"]):
|
||||||
try:
|
try:
|
||||||
os.makedirs(ConfigCache.root, exist_ok=True)
|
os.makedirs(ConfigCache.root, exist_ok=True)
|
||||||
if touch(path):
|
if touch(path):
|
||||||
# Toggle our status
|
# Toggle our status
|
||||||
response['can_write_config'] = True
|
response["can_write_config"] = True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# We can take an early exit as there is already a permission issue detected
|
# We can take an early exit as there is already a permission issue detected
|
||||||
response['details'].append('CONFIG_PERMISSION_ISSUE')
|
response["details"].append("CONFIG_PERMISSION_ISSUE")
|
||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
# We can take an early exit as there is already a permission issue detected
|
# We can take an early exit as there is already a permission issue detected
|
||||||
response['details'].append('CONFIG_PERMISSION_ISSUE')
|
response["details"].append("CONFIG_PERMISSION_ISSUE")
|
||||||
|
|
||||||
if settings.APPRISE_ATTACH_SIZE > 0:
|
if settings.APPRISE_ATTACH_SIZE > 0:
|
||||||
# Test our ability to access write attachments
|
# Test our ability to access write attachments
|
||||||
|
|
||||||
# Update our Configuration Check Block
|
# Update our Configuration Check Block
|
||||||
path = os.path.join(settings.APPRISE_ATTACH_DIR, '.tmp_hc')
|
path = os.path.join(settings.APPRISE_ATTACH_DIR, ".tmp_hc")
|
||||||
if lazy:
|
if lazy:
|
||||||
try:
|
try:
|
||||||
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
|
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
|
||||||
delta = (datetime.now() - modify_date).total_seconds()
|
delta = (datetime.now() - modify_date).total_seconds()
|
||||||
if delta <= 30.00: # 30s
|
if delta <= 30.00: # 30s
|
||||||
response['can_write_attach'] = True
|
response["can_write_attach"] = True
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# No worries... continue with below testing
|
# No worries... continue with below testing
|
||||||
@@ -896,23 +860,23 @@ def healthcheck(lazy=True):
|
|||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
# We can take an early exit as there is already a permission issue detected
|
# We can take an early exit as there is already a permission issue detected
|
||||||
response['details'].append('ATTACH_PERMISSION_ISSUE')
|
response["details"].append("ATTACH_PERMISSION_ISSUE")
|
||||||
|
|
||||||
if not (response['can_write_attach'] or 'ATTACH_PERMISSION_ISSUE' in response['details']):
|
if not (response["can_write_attach"] or "ATTACH_PERMISSION_ISSUE" in response["details"]):
|
||||||
# No lazy mode set or content require a refresh
|
# No lazy mode set or content require a refresh
|
||||||
try:
|
try:
|
||||||
os.makedirs(settings.APPRISE_ATTACH_DIR, exist_ok=True)
|
os.makedirs(settings.APPRISE_ATTACH_DIR, exist_ok=True)
|
||||||
if touch(path):
|
if touch(path):
|
||||||
# Toggle our status
|
# Toggle our status
|
||||||
response['can_write_attach'] = True
|
response["can_write_attach"] = True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# We can take an early exit as there is already a permission issue detected
|
# We can take an early exit as there is already a permission issue detected
|
||||||
response['details'].append('ATTACH_PERMISSION_ISSUE')
|
response["details"].append("ATTACH_PERMISSION_ISSUE")
|
||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
# We can take an early exit
|
# We can take an early exit
|
||||||
response['details'].append('ATTACH_PERMISSION_ISSUE')
|
response["details"].append("ATTACH_PERMISSION_ISSUE")
|
||||||
|
|
||||||
if settings.APPRISE_STORAGE_DIR:
|
if settings.APPRISE_STORAGE_DIR:
|
||||||
#
|
#
|
||||||
@@ -920,13 +884,13 @@ def healthcheck(lazy=True):
|
|||||||
#
|
#
|
||||||
store = apprise.PersistentStore(
|
store = apprise.PersistentStore(
|
||||||
path=settings.APPRISE_STORAGE_DIR,
|
path=settings.APPRISE_STORAGE_DIR,
|
||||||
namespace='tmp_hc',
|
namespace="tmp_hc",
|
||||||
mode=settings.APPRISE_STORAGE_MODE,
|
mode=settings.APPRISE_STORAGE_MODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
if store.mode != settings.APPRISE_STORAGE_MODE:
|
if store.mode != settings.APPRISE_STORAGE_MODE:
|
||||||
# Persistent storage not as configured
|
# Persistent storage not as configured
|
||||||
response['details'].append('STORE_PERMISSION_ISSUE')
|
response["details"].append("STORE_PERMISSION_ISSUE")
|
||||||
|
|
||||||
elif store.mode != apprise.PersistentStoreMode.MEMORY:
|
elif store.mode != apprise.PersistentStoreMode.MEMORY:
|
||||||
# G
|
# G
|
||||||
@@ -936,23 +900,23 @@ def healthcheck(lazy=True):
|
|||||||
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
|
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
|
||||||
delta = (datetime.now() - modify_date).total_seconds()
|
delta = (datetime.now() - modify_date).total_seconds()
|
||||||
if delta <= 30.00: # 30s
|
if delta <= 30.00: # 30s
|
||||||
response['persistent_storage'] = True
|
response["persistent_storage"] = True
|
||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
# No worries... continue with below testing
|
# No worries... continue with below testing
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not (store.set('foo', 'bar') and store.flush()):
|
if not (store.set("foo", "bar") and store.flush()):
|
||||||
# No persistent store
|
# No persistent store
|
||||||
response['details'].append('STORE_PERMISSION_ISSUE')
|
response["details"].append("STORE_PERMISSION_ISSUE")
|
||||||
else:
|
else:
|
||||||
# Toggle our status
|
# Toggle our status
|
||||||
response['persistent_storage'] = True
|
response["persistent_storage"] = True
|
||||||
|
|
||||||
# Clear our test
|
# Clear our test
|
||||||
store.clear('foo')
|
store.clear("foo")
|
||||||
|
|
||||||
if not response['details']:
|
if not response["details"]:
|
||||||
response['details'].append('OK')
|
response["details"].append("OK")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -30,7 +29,7 @@ def base_url(request):
|
|||||||
Returns our defined BASE_URL object
|
Returns our defined BASE_URL object
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
'BASE_URL': settings.BASE_URL,
|
"BASE_URL": settings.BASE_URL,
|
||||||
'CONFIG_DIR': settings.APPRISE_CONFIG_DIR,
|
"CONFIG_DIR": settings.APPRISE_CONFIG_DIR,
|
||||||
'ATTACH_DIR': settings.APPRISE_ATTACH_DIR,
|
"ATTACH_DIR": settings.APPRISE_ATTACH_DIR,
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
@@ -23,9 +22,10 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
#
|
#
|
||||||
import re
|
|
||||||
from django.conf import settings
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
class DetectConfigMiddleware:
|
class DetectConfigMiddleware:
|
||||||
@@ -35,7 +35,7 @@ class DetectConfigMiddleware:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_is_cfg_path = re.compile(r'/cfg/(?P<key>[\w_-]{1,128})')
|
_is_cfg_path = re.compile(r"/cfg/(?P<key>[\w_-]{1,128})")
|
||||||
|
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
"""
|
"""
|
||||||
@@ -51,14 +51,13 @@ class DetectConfigMiddleware:
|
|||||||
result = self._is_cfg_path.match(request.path)
|
result = self._is_cfg_path.match(request.path)
|
||||||
if not result:
|
if not result:
|
||||||
# Our current config
|
# Our current config
|
||||||
config = \
|
config = request.COOKIES.get("key", settings.APPRISE_DEFAULT_CONFIG_ID)
|
||||||
request.COOKIES.get('key', settings.APPRISE_DEFAULT_CONFIG_ID)
|
|
||||||
|
|
||||||
# Extract our key (fall back to our default if not set)
|
# Extract our key (fall back to our default if not set)
|
||||||
config = request.GET.get("key", config).strip()
|
config = request.GET.get("key", config).strip()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
config = result.group('key')
|
config = result.group("key")
|
||||||
|
|
||||||
if not config:
|
if not config:
|
||||||
# Fallback to default config
|
# Fallback to default config
|
||||||
@@ -72,11 +71,10 @@ class DetectConfigMiddleware:
|
|||||||
|
|
||||||
# Set our cookie
|
# Set our cookie
|
||||||
max_age = 365 * 24 * 60 * 60 # 1 year
|
max_age = 365 * 24 * 60 * 60 # 1 year
|
||||||
expires = datetime.datetime.utcnow() + \
|
expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=max_age)
|
||||||
datetime.timedelta(seconds=max_age)
|
|
||||||
|
|
||||||
# Set our cookie
|
# Set our cookie
|
||||||
response.set_cookie('key', config, expires=expires)
|
response.set_cookie("key", config, expires=expires)
|
||||||
|
|
||||||
# return our response
|
# return our response
|
||||||
return response
|
return response
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
@@ -23,10 +22,12 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
#
|
#
|
||||||
from django.conf import settings
|
|
||||||
from core.themes import SiteTheme, SITE_THEMES
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from core.themes import SITE_THEMES, SiteTheme
|
||||||
|
|
||||||
|
|
||||||
class AutoThemeMiddleware:
|
class AutoThemeMiddleware:
|
||||||
"""
|
"""
|
||||||
@@ -47,15 +48,18 @@ class AutoThemeMiddleware:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Our current theme
|
# Our current theme
|
||||||
current_theme = \
|
current_theme = request.COOKIES.get("t", request.COOKIES.get("theme", settings.APPRISE_DEFAULT_THEME))
|
||||||
request.COOKIES.get('t', request.COOKIES.get(
|
|
||||||
'theme', settings.APPRISE_DEFAULT_THEME))
|
|
||||||
|
|
||||||
# Extract our theme (fall back to our default if not set)
|
# Extract our theme (fall back to our default if not set)
|
||||||
theme = request.GET.get("theme", current_theme).strip().lower()
|
theme = request.GET.get("theme", current_theme).strip().lower()
|
||||||
theme = next((entry for entry in SITE_THEMES
|
theme = (
|
||||||
if entry.startswith(theme)), None) \
|
next(
|
||||||
if theme else None
|
(entry for entry in SITE_THEMES if entry.startswith(theme)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if theme
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
if theme not in SITE_THEMES:
|
if theme not in SITE_THEMES:
|
||||||
# Fallback to default theme
|
# Fallback to default theme
|
||||||
@@ -65,20 +69,17 @@ class AutoThemeMiddleware:
|
|||||||
request.theme = theme
|
request.theme = theme
|
||||||
|
|
||||||
# Set our next theme
|
# Set our next theme
|
||||||
request.next_theme = SiteTheme.LIGHT \
|
request.next_theme = SiteTheme.LIGHT if theme == SiteTheme.DARK else SiteTheme.DARK
|
||||||
if theme == SiteTheme.DARK \
|
|
||||||
else SiteTheme.DARK
|
|
||||||
|
|
||||||
# Get our response object
|
# Get our response object
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
|
|
||||||
# Set our cookie
|
# Set our cookie
|
||||||
max_age = 365 * 24 * 60 * 60 # 1 year
|
max_age = 365 * 24 * 60 * 60 # 1 year
|
||||||
expires = datetime.datetime.utcnow() + \
|
expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=max_age)
|
||||||
datetime.timedelta(seconds=max_age)
|
|
||||||
|
|
||||||
# Set our cookie
|
# Set our cookie
|
||||||
response.set_cookie('theme', theme, expires=expires)
|
response.set_cookie("theme", theme, expires=expires)
|
||||||
|
|
||||||
# return our response
|
# return our response
|
||||||
return response
|
return response
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
@@ -23,18 +22,17 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from core.themes import SiteTheme
|
from core.themes import SiteTheme
|
||||||
|
|
||||||
# Disable Timezones
|
# Disable Timezones
|
||||||
USE_TZ = False
|
USE_TZ = False
|
||||||
|
|
||||||
# Base Directory (relative to settings)
|
# Base Directory (relative to settings)
|
||||||
BASE_DIR = os.path.dirname(
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = os.environ.get(
|
SECRET_KEY = os.environ.get("SECRET_KEY", "+reua88v8rs4j!bcfdtinb-f0edxazf!$x_q1g7jtgckxd7gi=")
|
||||||
'SECRET_KEY', '+reua88v8rs4j!bcfdtinb-f0edxazf!$x_q1g7jtgckxd7gi=')
|
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
# If you want to run this app in DEBUG mode, run the following:
|
# If you want to run this app in DEBUG mode, run the following:
|
||||||
@@ -47,124 +45,115 @@ SECRET_KEY = os.environ.get(
|
|||||||
# ./manage.py runserver
|
# ./manage.py runserver
|
||||||
#
|
#
|
||||||
# Support 'yes', '1', 'true', 'enable', 'active', and +
|
# Support 'yes', '1', 'true', 'enable', 'active', and +
|
||||||
DEBUG = os.environ.get("DEBUG", 'No')[0].lower() in (
|
DEBUG = os.environ.get("DEBUG", "No")[0].lower() in (
|
||||||
'a', 'y', '1', 't', 'e', '+')
|
"a",
|
||||||
|
"y",
|
||||||
|
"1",
|
||||||
|
"t",
|
||||||
|
"e",
|
||||||
|
"+",
|
||||||
|
)
|
||||||
|
|
||||||
# allow all hosts by default otherwise read from the
|
# allow all hosts by default otherwise read from the
|
||||||
# ALLOWED_HOSTS environment variable
|
# ALLOWED_HOSTS environment variable
|
||||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(' ')
|
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(" ")
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
|
|
||||||
# Apprise API
|
# Apprise API
|
||||||
'api',
|
"api",
|
||||||
|
|
||||||
# Prometheus
|
# Prometheus
|
||||||
'django_prometheus',
|
"django_prometheus",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django_prometheus.middleware.PrometheusBeforeMiddleware',
|
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'core.middleware.theme.AutoThemeMiddleware',
|
"core.middleware.theme.AutoThemeMiddleware",
|
||||||
'core.middleware.config.DetectConfigMiddleware',
|
"core.middleware.config.DetectConfigMiddleware",
|
||||||
'django_prometheus.middleware.PrometheusAfterMiddleware',
|
"django_prometheus.middleware.PrometheusAfterMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'core.urls'
|
ROOT_URLCONF = "core.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [],
|
"DIRS": [],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": True,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.request",
|
||||||
'core.context_processors.base_url',
|
"core.context_processors.base_url",
|
||||||
'api.context_processors.default_config_id',
|
"api.context_processors.default_config_id",
|
||||||
'api.context_processors.unique_config_id',
|
"api.context_processors.unique_config_id",
|
||||||
'api.context_processors.stateful_mode',
|
"api.context_processors.stateful_mode",
|
||||||
'api.context_processors.config_lock',
|
"api.context_processors.config_lock",
|
||||||
'api.context_processors.admin_enabled',
|
"api.context_processors.admin_enabled",
|
||||||
'api.context_processors.apprise_version',
|
"api.context_processors.apprise_version",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
"version": 1,
|
||||||
'disable_existing_loggers': True,
|
"disable_existing_loggers": True,
|
||||||
'formatters': {
|
"formatters": {
|
||||||
'standard': {
|
"standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"},
|
||||||
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {"class": "logging.StreamHandler", "formatter": "standard"},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"django": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": os.environ.get("LOG_LEVEL", "debug" if DEBUG else "info").upper(),
|
||||||
|
},
|
||||||
|
"apprise": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": os.environ.get("LOG_LEVEL", "debug" if DEBUG else "info").upper(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'handlers': {
|
|
||||||
'console': {
|
|
||||||
'class': 'logging.StreamHandler',
|
|
||||||
'formatter': 'standard'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'loggers': {
|
|
||||||
'django': {
|
|
||||||
'handlers': ['console'],
|
|
||||||
'level': os.environ.get(
|
|
||||||
'LOG_LEVEL', 'debug' if DEBUG else 'info').upper(),
|
|
||||||
},
|
|
||||||
'apprise': {
|
|
||||||
'handlers': ['console'],
|
|
||||||
'level': os.environ.get(
|
|
||||||
'LOG_LEVEL', 'debug' if DEBUG else 'info').upper(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
WSGI_APPLICATION = 'core.wsgi.application'
|
WSGI_APPLICATION = "core.wsgi.application"
|
||||||
|
|
||||||
# Define our base URL
|
# Define our base URL
|
||||||
# The default value is to be a single slash
|
# The default value is to be a single slash
|
||||||
BASE_URL = os.environ.get('BASE_URL', '')
|
BASE_URL = os.environ.get("BASE_URL", "")
|
||||||
|
|
||||||
# Define our default configuration ID to use
|
# Define our default configuration ID to use
|
||||||
APPRISE_DEFAULT_CONFIG_ID = \
|
APPRISE_DEFAULT_CONFIG_ID = os.environ.get("APPRISE_DEFAULT_CONFIG_ID", "apprise")
|
||||||
os.environ.get('APPRISE_DEFAULT_CONFIG_ID', 'apprise')
|
|
||||||
|
|
||||||
# Define our Prometheus Namespace
|
# Define our Prometheus Namespace
|
||||||
PROMETHEUS_METRIC_NAMESPACE = "apprise"
|
PROMETHEUS_METRIC_NAMESPACE = "apprise"
|
||||||
|
|
||||||
# Static files relative path (CSS, JavaScript, Images)
|
# Static files relative path (CSS, JavaScript, Images)
|
||||||
STATIC_URL = BASE_URL + '/s/'
|
STATIC_URL = BASE_URL + "/s/"
|
||||||
|
|
||||||
# Default theme can be either 'light' or 'dark'
|
# Default theme can be either 'light' or 'dark'
|
||||||
APPRISE_DEFAULT_THEME = \
|
APPRISE_DEFAULT_THEME = os.environ.get("APPRISE_DEFAULT_THEME", SiteTheme.LIGHT)
|
||||||
os.environ.get('APPRISE_DEFAULT_THEME', SiteTheme.LIGHT)
|
|
||||||
|
|
||||||
# Webhook that is posted to upon executed results
|
# Webhook that is posted to upon executed results
|
||||||
# Set it to something like https://myserver.com/path/
|
# Set it to something like https://myserver.com/path/
|
||||||
# Requets are done as a POST
|
# Requets are done as a POST
|
||||||
APPRISE_WEBHOOK_URL = os.environ.get('APPRISE_WEBHOOK_URL', '')
|
APPRISE_WEBHOOK_URL = os.environ.get("APPRISE_WEBHOOK_URL", "")
|
||||||
|
|
||||||
# The location to store Apprise configuration files
|
# The location to store Apprise configuration files
|
||||||
APPRISE_CONFIG_DIR = os.environ.get(
|
APPRISE_CONFIG_DIR = os.environ.get("APPRISE_CONFIG_DIR", os.path.join(BASE_DIR, "var", "config"))
|
||||||
'APPRISE_CONFIG_DIR', os.path.join(BASE_DIR, 'var', 'config'))
|
|
||||||
|
|
||||||
# The location to store Apprise Persistent Storage files
|
# The location to store Apprise Persistent Storage files
|
||||||
APPRISE_STORAGE_DIR = os.environ.get(
|
APPRISE_STORAGE_DIR = os.environ.get("APPRISE_STORAGE_DIR", os.path.join(APPRISE_CONFIG_DIR, "store"))
|
||||||
'APPRISE_STORAGE_DIR', os.path.join(APPRISE_CONFIG_DIR, 'store'))
|
|
||||||
|
|
||||||
# Default number of days to prune persistent storage
|
# Default number of days to prune persistent storage
|
||||||
APPRISE_STORAGE_PRUNE_DAYS = \
|
APPRISE_STORAGE_PRUNE_DAYS = int(os.environ.get("APPRISE_STORAGE_PRUNE_DAYS", 30))
|
||||||
int(os.environ.get('APPRISE_STORAGE_PRUNE_DAYS', 30))
|
|
||||||
|
|
||||||
# The default URL ID Length
|
# The default URL ID Length
|
||||||
APPRISE_STORAGE_UID_LENGTH = \
|
APPRISE_STORAGE_UID_LENGTH = int(os.environ.get("APPRISE_STORAGE_UID_LENGTH", 8))
|
||||||
int(os.environ.get('APPRISE_STORAGE_UID_LENGTH', 8))
|
|
||||||
|
|
||||||
# The default storage mode; options are:
|
# The default storage mode; options are:
|
||||||
# - memory : Disables persistent storage (this is also automatically set
|
# - memory : Disables persistent storage (this is also automatically set
|
||||||
@@ -174,14 +163,13 @@ APPRISE_STORAGE_UID_LENGTH = \
|
|||||||
# - flush : Writes to storage constantly (as much as possible). This
|
# - flush : Writes to storage constantly (as much as possible). This
|
||||||
# produces more i/o but can allow multiple calls to the same
|
# produces more i/o but can allow multiple calls to the same
|
||||||
# notification to be in sync more
|
# notification to be in sync more
|
||||||
APPRISE_STORAGE_MODE = os.environ.get('APPRISE_STORAGE_MODE', 'auto').lower()
|
APPRISE_STORAGE_MODE = os.environ.get("APPRISE_STORAGE_MODE", "auto").lower()
|
||||||
|
|
||||||
# The location to place file attachments
|
# The location to place file attachments
|
||||||
APPRISE_ATTACH_DIR = os.environ.get(
|
APPRISE_ATTACH_DIR = os.environ.get("APPRISE_ATTACH_DIR", os.path.join(BASE_DIR, "var", "attach"))
|
||||||
'APPRISE_ATTACH_DIR', os.path.join(BASE_DIR, 'var', 'attach'))
|
|
||||||
|
|
||||||
# The maximum file attachment size allowed by the API (defined in MB)
|
# The maximum file attachment size allowed by the API (defined in MB)
|
||||||
APPRISE_ATTACH_SIZE = int(os.environ.get('APPRISE_ATTACH_SIZE', 200)) * 1048576
|
APPRISE_ATTACH_SIZE = int(os.environ.get("APPRISE_ATTACH_SIZE", 200)) * 1048576
|
||||||
|
|
||||||
# A provided list that identify all of the URLs/Hosts/IPs that Apprise can
|
# A provided list that identify all of the URLs/Hosts/IPs that Apprise can
|
||||||
# retrieve remote attachments from.
|
# retrieve remote attachments from.
|
||||||
@@ -210,18 +198,15 @@ APPRISE_ATTACH_SIZE = int(os.environ.get('APPRISE_ATTACH_SIZE', 200)) * 1048576
|
|||||||
# the URL based attachment is ignored and is not retrieved at all.
|
# the URL based attachment is ignored and is not retrieved at all.
|
||||||
# - Set the list to * (a single astrix) to match all URLs and accepting all provided
|
# - Set the list to * (a single astrix) to match all URLs and accepting all provided
|
||||||
# matches
|
# matches
|
||||||
APPRISE_ATTACH_DENY_URLS = \
|
APPRISE_ATTACH_DENY_URLS = os.environ.get("APPRISE_ATTACH_REJECT_URL", "127.0.* localhost*").lower()
|
||||||
os.environ.get('APPRISE_ATTACH_REJECT_URL', '127.0.* localhost*').lower()
|
|
||||||
|
|
||||||
# The Allow list which is processed after the Deny list above
|
# The Allow list which is processed after the Deny list above
|
||||||
APPRISE_ATTACH_ALLOW_URLS = \
|
APPRISE_ATTACH_ALLOW_URLS = os.environ.get("APPRISE_ATTACH_ALLOW_URL", "*").lower()
|
||||||
os.environ.get('APPRISE_ATTACH_ALLOW_URL', '*').lower()
|
|
||||||
|
|
||||||
|
|
||||||
# The maximum size in bytes that a request body may be before raising an error
|
# The maximum size in bytes that a request body may be before raising an error
|
||||||
# (defined in MB)
|
# (defined in MB)
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = abs(int(os.environ.get(
|
DATA_UPLOAD_MAX_MEMORY_SIZE = abs(int(os.environ.get("APPRISE_UPLOAD_MAX_MEMORY_SIZE", 3))) * 1048576
|
||||||
'APPRISE_UPLOAD_MAX_MEMORY_SIZE', 3))) * 1048576
|
|
||||||
|
|
||||||
# When set Apprise API Locks itself down so that future (configuration)
|
# When set Apprise API Locks itself down so that future (configuration)
|
||||||
# changes can not be made or accessed. It disables access to:
|
# changes can not be made or accessed. It disables access to:
|
||||||
@@ -239,55 +224,64 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = abs(int(os.environ.get(
|
|||||||
# The idea here is that someone has set up the configuration they way they want
|
# The idea here is that someone has set up the configuration they way they want
|
||||||
# and do not want this information exposed any more then it needs to be.
|
# and do not want this information exposed any more then it needs to be.
|
||||||
# it's a lock down mode if you will.
|
# it's a lock down mode if you will.
|
||||||
APPRISE_CONFIG_LOCK = \
|
APPRISE_CONFIG_LOCK = os.environ.get("APPRISE_CONFIG_LOCK", "no")[0].lower() in ("a", "y", "1", "t", "e", "+")
|
||||||
os.environ.get("APPRISE_CONFIG_LOCK", 'no')[0].lower() in (
|
|
||||||
'a', 'y', '1', 't', 'e', '+')
|
|
||||||
|
|
||||||
# Stateless posts to /notify/ will resort to this set of URLs if none
|
# Stateless posts to /notify/ will resort to this set of URLs if none
|
||||||
# were otherwise posted with the URL request.
|
# were otherwise posted with the URL request.
|
||||||
APPRISE_STATELESS_URLS = os.environ.get('APPRISE_STATELESS_URLS', '')
|
APPRISE_STATELESS_URLS = os.environ.get("APPRISE_STATELESS_URLS", "")
|
||||||
|
|
||||||
# Allow stateless URLS to generate and/or work with persistent storage
|
# Allow stateless URLS to generate and/or work with persistent storage
|
||||||
# By default this is set to no
|
# By default this is set to no
|
||||||
APPRISE_STATELESS_STORAGE = \
|
APPRISE_STATELESS_STORAGE = os.environ.get("APPRISE_STATELESS_STORAGE", "no")[0].lower() in (
|
||||||
os.environ.get("APPRISE_STATELESS_STORAGE", 'no')[0].lower() in (
|
"a",
|
||||||
'a', 'y', '1', 't', 'e', '+')
|
"y",
|
||||||
|
"1",
|
||||||
|
"t",
|
||||||
|
"e",
|
||||||
|
"+",
|
||||||
|
)
|
||||||
|
|
||||||
# Defines the stateful mode; possible values are:
|
# Defines the stateful mode; possible values are:
|
||||||
# - hash (default): content is hashed and zipped
|
# - hash (default): content is hashed and zipped
|
||||||
# - simple: content is just written straight to disk 'as-is'
|
# - simple: content is just written straight to disk 'as-is'
|
||||||
# - disabled: disable all stateful functionality
|
# - disabled: disable all stateful functionality
|
||||||
APPRISE_STATEFUL_MODE = os.environ.get('APPRISE_STATEFUL_MODE', 'hash')
|
APPRISE_STATEFUL_MODE = os.environ.get("APPRISE_STATEFUL_MODE", "hash")
|
||||||
|
|
||||||
# Our Apprise Deny List
|
# Our Apprise Deny List
|
||||||
# - By default we disable all non-remote calling services
|
# - By default we disable all non-remote calling services
|
||||||
# - You do not need to identify every schema supported by the service you
|
# - You do not need to identify every schema supported by the service you
|
||||||
# wish to disable (only one). For example, if you were to specify
|
# wish to disable (only one). For example, if you were to specify
|
||||||
# xml, that would include the xmls entry as well (or vs versa)
|
# xml, that would include the xmls entry as well (or vs versa)
|
||||||
APPRISE_DENY_SERVICES = os.environ.get('APPRISE_DENY_SERVICES', ','.join((
|
APPRISE_DENY_SERVICES = os.environ.get(
|
||||||
'windows', 'dbus', 'gnome', 'macosx', 'syslog')))
|
"APPRISE_DENY_SERVICES",
|
||||||
|
",".join(("windows", "dbus", "gnome", "macosx", "syslog")),
|
||||||
|
)
|
||||||
|
|
||||||
# Our Apprise Exclusive Allow List
|
# Our Apprise Exclusive Allow List
|
||||||
# - anything not identified here is denied/disabled)
|
# - anything not identified here is denied/disabled)
|
||||||
# - this list trumps the APPRISE_DENY_SERVICES identified above
|
# - this list trumps the APPRISE_DENY_SERVICES identified above
|
||||||
APPRISE_ALLOW_SERVICES = os.environ.get('APPRISE_ALLOW_SERVICES', '')
|
APPRISE_ALLOW_SERVICES = os.environ.get("APPRISE_ALLOW_SERVICES", "")
|
||||||
|
|
||||||
# Define the number of recursive calls your system will allow users to make
|
# Define the number of recursive calls your system will allow users to make
|
||||||
# The idea here is to prevent people from defining apprise:// URL's triggering
|
# The idea here is to prevent people from defining apprise:// URL's triggering
|
||||||
# a call to the same server again, and again and again. By default we allow
|
# a call to the same server again, and again and again. By default we allow
|
||||||
# 1 level of recursion
|
# 1 level of recursion
|
||||||
APPRISE_RECURSION_MAX = int(os.environ.get('APPRISE_RECURSION_MAX', 1))
|
APPRISE_RECURSION_MAX = int(os.environ.get("APPRISE_RECURSION_MAX", 1))
|
||||||
|
|
||||||
# Provided optional plugin paths to scan for custom schema definitions
|
# Provided optional plugin paths to scan for custom schema definitions
|
||||||
APPRISE_PLUGIN_PATHS = os.environ.get(
|
APPRISE_PLUGIN_PATHS = os.environ.get("APPRISE_PLUGIN_PATHS", os.path.join(BASE_DIR, "var", "plugin")).split(",")
|
||||||
'APPRISE_PLUGIN_PATHS', os.path.join(BASE_DIR, 'var', 'plugin')).split(',')
|
|
||||||
|
|
||||||
# Define the number of attachments that can exist as part of a payload
|
# Define the number of attachments that can exist as part of a payload
|
||||||
# Setting this to zero disables the limit
|
# Setting this to zero disables the limit
|
||||||
APPRISE_MAX_ATTACHMENTS = int(os.environ.get('APPRISE_MAX_ATTACHMENTS', 6))
|
APPRISE_MAX_ATTACHMENTS = int(os.environ.get("APPRISE_MAX_ATTACHMENTS", 6))
|
||||||
|
|
||||||
# Allow Admin mode:
|
# Allow Admin mode:
|
||||||
# - showing a list of configuration keys (when STATEFUL_MODE is set to simple)
|
# - showing a list of configuration keys (when STATEFUL_MODE is set to simple)
|
||||||
APPRISE_ADMIN = \
|
APPRISE_ADMIN = os.environ.get("APPRISE_ADMIN", "no")[0].lower() in (
|
||||||
os.environ.get("APPRISE_ADMIN", 'no')[0].lower() in (
|
"a",
|
||||||
'a', 'y', '1', 't', 'e', '+')
|
"y",
|
||||||
|
"1",
|
||||||
|
"t",
|
||||||
|
"e",
|
||||||
|
"+",
|
||||||
|
)
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -26,6 +25,7 @@
|
|||||||
# To create a valid debug settings.py we need to intentionally pollute our
|
# To create a valid debug settings.py we need to intentionally pollute our
|
||||||
# file with all of the content found in the master configuration.
|
# file with all of the content found in the master configuration.
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from .. import * # noqa F403
|
from .. import * # noqa F403
|
||||||
|
|
||||||
# Debug is always on when running in debug mode
|
# Debug is always on when running in debug mode
|
||||||
@@ -35,9 +35,7 @@ DEBUG = True
|
|||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
# Over-ride the default URLConf for debugging
|
# Over-ride the default URLConf for debugging
|
||||||
ROOT_URLCONF = 'core.settings.debug.urls'
|
ROOT_URLCONF = "core.settings.debug.urls"
|
||||||
|
|
||||||
# Our static paths directory for serving
|
# Our static paths directory for serving
|
||||||
STATICFILES_DIRS = (
|
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa F405
|
||||||
os.path.join(BASE_DIR, 'static'), # noqa F405
|
|
||||||
)
|
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -24,6 +23,7 @@
|
|||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
from ...urls import * # noqa F403
|
from ...urls import * # noqa F403
|
||||||
|
|
||||||
# Extend our patterns
|
# Extend our patterns
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -26,6 +25,7 @@
|
|||||||
# To create a valid debug settings.py we need to intentionally pollute our
|
# To create a valid debug settings.py we need to intentionally pollute our
|
||||||
# file with all of the content found in the master configuration.
|
# file with all of the content found in the master configuration.
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from .. import * # noqa F403
|
from .. import * # noqa F403
|
||||||
|
|
||||||
# Debug is always on when running in debug mode
|
# Debug is always on when running in debug mode
|
||||||
@@ -38,4 +38,4 @@ ALLOWED_HOSTS = []
|
|||||||
APPRISE_CONFIG_DIR = TemporaryDirectory().name
|
APPRISE_CONFIG_DIR = TemporaryDirectory().name
|
||||||
|
|
||||||
# Setup our runner
|
# Setup our runner
|
||||||
TEST_RUNNER = 'core.settings.pytest.runner.PytestTestRunner'
|
TEST_RUNNER = "core.settings.pytest.runner.PytestTestRunner"
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -27,7 +26,7 @@
|
|||||||
# file with all of the content found in the master configuration.
|
# file with all of the content found in the master configuration.
|
||||||
|
|
||||||
|
|
||||||
class PytestTestRunner(object):
|
class PytestTestRunner:
|
||||||
"""Runs pytest to discover and run tests."""
|
"""Runs pytest to discover and run tests."""
|
||||||
|
|
||||||
def __init__(self, verbosity=1, failfast=False, keepdb=False, **kwargs):
|
def __init__(self, verbosity=1, failfast=False, keepdb=False, **kwargs):
|
||||||
@@ -44,15 +43,15 @@ class PytestTestRunner(object):
|
|||||||
|
|
||||||
argv = []
|
argv = []
|
||||||
if self.verbosity == 0:
|
if self.verbosity == 0:
|
||||||
argv.append('--quiet')
|
argv.append("--quiet")
|
||||||
if self.verbosity == 2:
|
if self.verbosity == 2:
|
||||||
argv.append('--verbose')
|
argv.append("--verbose")
|
||||||
if self.verbosity == 3:
|
if self.verbosity == 3:
|
||||||
argv.append('-vv')
|
argv.append("-vv")
|
||||||
if self.failfast:
|
if self.failfast:
|
||||||
argv.append('--exitfirst')
|
argv.append("--exitfirst")
|
||||||
if self.keepdb:
|
if self.keepdb:
|
||||||
argv.append('--reuse-db')
|
argv.append("--reuse-db")
|
||||||
|
|
||||||
argv.extend(test_labels)
|
argv.extend(test_labels)
|
||||||
return pytest.main(argv)
|
return pytest.main(argv)
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -28,8 +27,9 @@ class SiteTheme:
|
|||||||
"""
|
"""
|
||||||
Defines our site themes
|
Defines our site themes
|
||||||
"""
|
"""
|
||||||
LIGHT = 'light'
|
|
||||||
DARK = 'dark'
|
LIGHT = "light"
|
||||||
|
DARK = "dark"
|
||||||
|
|
||||||
|
|
||||||
SITE_THEMES = (
|
SITE_THEMES = (
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -22,12 +21,11 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
from django.urls import path
|
|
||||||
from django.conf.urls import include
|
|
||||||
|
|
||||||
from api import urls as api_urls
|
from api import urls as api_urls
|
||||||
|
from django.conf.urls import include
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(api_urls)),
|
path("", include(api_urls)),
|
||||||
path('', include('django_prometheus.urls')),
|
path("", include("django_prometheus.urls")),
|
||||||
]
|
]
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -23,7 +22,8 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
@@ -22,36 +21,35 @@
|
|||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
import os
|
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
|
||||||
# This file is launched with the call:
|
# This file is launched with the call:
|
||||||
# gunicorn --config <this file> core.wsgi:application
|
# gunicorn --config <this file> core.wsgi:application
|
||||||
|
|
||||||
raw_env = [
|
raw_env = [
|
||||||
'LANG={}'.format(os.environ.get('LANG', 'en_US.UTF-8')),
|
"LANG={}".format(os.environ.get("LANG", "en_US.UTF-8")),
|
||||||
'DJANGO_SETTINGS_MODULE=core.settings',
|
"DJANGO_SETTINGS_MODULE=core.settings",
|
||||||
]
|
]
|
||||||
|
|
||||||
# This is the path as prepared in the docker compose
|
# This is the path as prepared in the docker compose
|
||||||
pythonpath = '/opt/apprise/webapp'
|
pythonpath = "/opt/apprise/webapp"
|
||||||
|
|
||||||
# bind to port 8000
|
# bind to port 8000
|
||||||
bind = [
|
bind = [
|
||||||
'0.0.0.0:8000', # IPv4 Support
|
"0.0.0.0:8000", # IPv4 Support
|
||||||
'[::]:8000', # IPv6 Support
|
"[::]:8000", # IPv6 Support
|
||||||
]
|
]
|
||||||
|
|
||||||
# Workers are relative to the number of CPU's provided by hosting server
|
# Workers are relative to the number of CPU's provided by hosting server
|
||||||
workers = int(os.environ.get(
|
workers = int(os.environ.get("APPRISE_WORKER_COUNT", multiprocessing.cpu_count() * 2 + 1))
|
||||||
'APPRISE_WORKER_COUNT', multiprocessing.cpu_count() * 2 + 1))
|
|
||||||
|
|
||||||
# Increase worker timeout value to give upstream services time to
|
# Increase worker timeout value to give upstream services time to
|
||||||
# respond.
|
# respond.
|
||||||
timeout = int(os.environ.get('APPRISE_WORKER_TIMEOUT', 300))
|
timeout = int(os.environ.get("APPRISE_WORKER_TIMEOUT", 300))
|
||||||
|
|
||||||
# Our worker type to use; over-ride the default `sync`
|
# Our worker type to use; over-ride the default `sync`
|
||||||
worker_class = 'gevent'
|
worker_class = "gevent"
|
||||||
|
|
||||||
# Get workers memory consumption under control by leveraging gunicorn
|
# Get workers memory consumption under control by leveraging gunicorn
|
||||||
# worker recycling timeout
|
# worker recycling timeout
|
||||||
@@ -60,6 +58,6 @@ max_requests_jitter = 50
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
# '-' means log to stdout.
|
# '-' means log to stdout.
|
||||||
errorlog = '-'
|
errorlog = "-"
|
||||||
accesslog = '-'
|
accesslog = "-"
|
||||||
loglevel = 'warn'
|
loglevel = "warn"
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -28,7 +27,7 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.debug')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.debug")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -40,5 +39,5 @@ def main():
|
|||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
flake8
|
|
||||||
mock
|
mock
|
||||||
pytest-django
|
pytest-django
|
||||||
pytest
|
pytest
|
||||||
pytest-cov
|
pytest-cov
|
||||||
tox
|
tox
|
||||||
|
ruff
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
apprise:
|
apprise-api:
|
||||||
build: .
|
build: .
|
||||||
container_name: apprise
|
container_name: apprise-api
|
||||||
environment:
|
environment:
|
||||||
- APPRISE_STATEFUL_MODE=simple
|
- APPRISE_STATEFUL_MODE=simple
|
||||||
ports:
|
ports:
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
@@ -27,13 +26,12 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Update our path so it will see our apprise_api content
|
# Update our path so it will see our apprise_api content
|
||||||
sys.path.insert(
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "apprise_api"))
|
||||||
0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'apprise_api'))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Unless otherwise specified, default to a debug mode
|
# Unless otherwise specified, default to a debug mode
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.debug')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.debug")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -45,5 +43,5 @@ def main():
|
|||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
179
pyproject.toml
Normal file
179
pyproject.toml
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
# This code is licensed under the MIT License.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files(the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions :
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "apprise-api"
|
||||||
|
version = "1.2.1"
|
||||||
|
authors = [{ name = "Chris Caron", email = "lead2gold@gmail.com" }]
|
||||||
|
license = "MIT"
|
||||||
|
dependencies = [
|
||||||
|
"apprise == 1.9.4",
|
||||||
|
"django",
|
||||||
|
"gevent",
|
||||||
|
"gunicorn",
|
||||||
|
"requests",
|
||||||
|
"paho-mqtt < 2.0.0",
|
||||||
|
"gntp",
|
||||||
|
"cryptography",
|
||||||
|
"django-prometheus"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
"pytest-cov",
|
||||||
|
"pytest-django",
|
||||||
|
"ruff",
|
||||||
|
"tox",
|
||||||
|
"mock"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
license-files = ["LICENSE"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["apprise_api*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE = "core.settings.pytest"
|
||||||
|
addopts = "--ignore=lib --ignore=lib64 --nomigrations --cov=apprise_api --cov-report=term-missing"
|
||||||
|
filterwarnings = ["once::Warning"]
|
||||||
|
testpaths = ["apprise_api"]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
data_file = ".coverage-reports/.coverage"
|
||||||
|
parallel = false
|
||||||
|
concurrency = ["multiprocessing"]
|
||||||
|
include = ["apprise_api"]
|
||||||
|
omit = [
|
||||||
|
"*apps.py",
|
||||||
|
"*/migrations/*",
|
||||||
|
"*/core/settings/*",
|
||||||
|
"*/*/tests/*",
|
||||||
|
"lib/*",
|
||||||
|
"lib64/*",
|
||||||
|
"*urls.py",
|
||||||
|
"*/core/wsgi.py",
|
||||||
|
"gunicorn.conf.py",
|
||||||
|
"*/manage.py"
|
||||||
|
]
|
||||||
|
disable_warnings = ["no-data-collected"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = true
|
||||||
|
skip_empty = true
|
||||||
|
fail_under = 75.0
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py312"
|
||||||
|
exclude = [
|
||||||
|
"tests/data",
|
||||||
|
"bin",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
".eggs",
|
||||||
|
".tox",
|
||||||
|
".local",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
# Added for backwards support and alternative cleanup
|
||||||
|
# when needed
|
||||||
|
line-length = 79 # Respecting BSD-style 79-char limit
|
||||||
|
target-version = ['py39']
|
||||||
|
extend-exclude = '''
|
||||||
|
/(
|
||||||
|
tests/data \
|
||||||
|
bin \
|
||||||
|
build \
|
||||||
|
dist \
|
||||||
|
.eggs \
|
||||||
|
.tox \
|
||||||
|
.local \
|
||||||
|
.venv \
|
||||||
|
venv
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"F", # pyflakes
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"Q", # Quote handling ' -> "
|
||||||
|
"I", # isort
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"SIM", # flake8-simplify
|
||||||
|
"T20", # flake8-print (catches stray `print`)
|
||||||
|
"RUF", # Ruff-native rules
|
||||||
|
]
|
||||||
|
|
||||||
|
extend-select = [
|
||||||
|
"E501", # Spacing
|
||||||
|
"Q000", # Quoting
|
||||||
|
"I" # Automatically sort imports with isort logic
|
||||||
|
]
|
||||||
|
|
||||||
|
ignore = [
|
||||||
|
"D100", # missing docstring in public module
|
||||||
|
"D104", # missing docstring in public package
|
||||||
|
"B008", # do not call `dict()` with keyword args
|
||||||
|
"E722", # bare except (Apprise uses it reasonably)
|
||||||
|
"E741", # Ambiguous variable name (e.g., l, O, I)
|
||||||
|
"W605", # Invalid escape sequence
|
||||||
|
"B026", # Star-arg play a big part of Apprise; must be accepted for now
|
||||||
|
|
||||||
|
# The following needs to be supported at some point and is of great value
|
||||||
|
"RUF012", # typing.ClassVar implimentation
|
||||||
|
"UP032", # This is a massive undertaking and requires a massive rewrite
|
||||||
|
# of test cases; this will take a while to fix, so turning off
|
||||||
|
# for now
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.pyupgrade]
|
||||||
|
keep-runtime-typing = true
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-quotes]
|
||||||
|
inline-quotes = "double"
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["apprise"]
|
||||||
|
force-sort-within-sections = true
|
||||||
|
combine-as-imports = true
|
||||||
|
order-by-type = true
|
||||||
|
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-builtins]
|
||||||
|
builtins-ignorelist = ["_"]
|
||||||
|
|
20
setup.cfg
20
setup.cfg
@@ -1,20 +0,0 @@
|
|||||||
[metadata]
|
|
||||||
# ensure LICENSE is included in wheel metadata
|
|
||||||
license_file = LICENSE
|
|
||||||
|
|
||||||
[flake8]
|
|
||||||
# We exclude packages we don't maintain
|
|
||||||
exclude = .eggs,.tox
|
|
||||||
ignore = E741,E722,W503,W504,W605
|
|
||||||
statistics = true
|
|
||||||
builtins = _
|
|
||||||
max-line-length = 160
|
|
||||||
|
|
||||||
[aliases]
|
|
||||||
test=pytest
|
|
||||||
|
|
||||||
[tool:pytest]
|
|
||||||
DJANGO_SETTINGS_MODULE = core.settings.pytest
|
|
||||||
addopts = --ignore=lib --ignore=lib64 --nomigrations --cov=apprise_api --cov-report=term-missing
|
|
||||||
filterwarnings =
|
|
||||||
once::Warning
|
|
70
tox.ini
70
tox.ini
@@ -1,30 +1,54 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = py312,coverage-report
|
envlist =
|
||||||
skipsdist = true
|
clean
|
||||||
|
test
|
||||||
|
lint
|
||||||
|
format
|
||||||
|
qa
|
||||||
|
coverage
|
||||||
|
|
||||||
|
skip_missing_interpreters = true
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
# Prevent random setuptools/pip breakages like
|
skip_install = false
|
||||||
# https://github.com/pypa/setuptools/issues/1042 from breaking our builds.
|
usedevelop = true
|
||||||
|
changedir = {toxinidir}
|
||||||
|
allowlist_externals = *
|
||||||
|
ensurepip = true
|
||||||
setenv =
|
setenv =
|
||||||
VIRTUALENV_NO_DOWNLOAD=1
|
COVERAGE_RCFILE = {toxinidir}/pyproject.toml
|
||||||
deps=
|
|
||||||
-r{toxinidir}/requirements.txt
|
|
||||||
-r{toxinidir}/dev-requirements.txt
|
|
||||||
commands =
|
|
||||||
coverage run --parallel -m pytest {posargs} apprise_api
|
|
||||||
flake8 apprise_api --count --show-source --statistics
|
|
||||||
|
|
||||||
[testenv:py312]
|
[testenv:lint]
|
||||||
deps=
|
description = Run Ruff in check-only mode
|
||||||
-r{toxinidir}/requirements.txt
|
deps = ruff
|
||||||
-r{toxinidir}/dev-requirements.txt
|
commands = ruff check apprise_api
|
||||||
commands =
|
|
||||||
coverage run --parallel -m pytest {posargs} apprise_api
|
|
||||||
flake8 apprise_api --count --show-source --statistics
|
|
||||||
|
|
||||||
[testenv:coverage-report]
|
[testenv:format]
|
||||||
deps = coverage
|
description = Auto-format code using Ruff
|
||||||
|
deps = ruff
|
||||||
skip_install = true
|
skip_install = true
|
||||||
commands=
|
commands = ruff check --fix apprise_api
|
||||||
coverage combine apprise_api
|
|
||||||
coverage report apprise_api
|
[testenv:qa]
|
||||||
|
description = Run test suite and linting with coverage
|
||||||
|
deps =
|
||||||
|
.[dev]
|
||||||
|
commands =
|
||||||
|
coverage run --parallel -m pytest apprise_api
|
||||||
|
ruff check apprise_api
|
||||||
|
|
||||||
|
[testenv:test]
|
||||||
|
description = Run test suite only
|
||||||
|
deps =
|
||||||
|
.[dev]
|
||||||
|
commands =
|
||||||
|
pytest --tb=short -q apprise_api {posargs}
|
||||||
|
|
||||||
|
[testenv:coverage]
|
||||||
|
description = Generates coverage details
|
||||||
|
deps =
|
||||||
|
coverage
|
||||||
|
commands =
|
||||||
|
coverage combine apprise_api
|
||||||
|
coverage report apprise_api
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user