Refactored code base; modernized with pyproject.toml (#255)

This commit is contained in:
Chris Caron
2025-08-05 16:53:42 -04:00
committed by GitHub
parent d1108216a0
commit c571bb0671
54 changed files with 3689 additions and 3066 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -16,6 +16,7 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
.local/
lib64 lib64
lib lib
var/ var/

View File

@@ -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:

View File

@@ -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.

View File

@@ -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"

View File

@@ -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()}

View File

@@ -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,
) )

View File

@@ -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']})"))

View File

@@ -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

View File

@@ -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

View File

@@ -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, {})

View File

@@ -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)

View File

@@ -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)

View 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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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",
]} ],
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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)

View File

@@ -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"))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:
- URLbased 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).
- Hostbased 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 URLbased 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 hostbased token (one with no “/”) into a regex. Compiles a host-based token (one with no "/") into a regex.
Note: When matching hostbased tokens, we require that the URLs 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 URLs 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:

View File

@@ -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'),
] ]

View File

@@ -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

View File

@@ -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,
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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",
"+",
)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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 = (

View File

@@ -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")),
] ]

View File

@@ -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()

View File

@@ -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"

View File

@@ -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()

View File

@@ -1,6 +1,6 @@
flake8
mock mock
pytest-django pytest-django
pytest pytest
pytest-cov pytest-cov
tox tox
ruff

View File

@@ -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:

View File

@@ -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
View 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 = ["_"]

View File

@@ -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
View File

@@ -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