diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 2630924..0000000 --- a/.coveragerc +++ /dev/null @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 59f456c..45b7724 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,9 @@ ## Description: -**Related issue (if applicable):** refs # +**Related issue (if applicable):** refs # ## Checklist * [ ] The code change is tested and works locally. * [ ] There is no commented out code in this PR. -* [ ] No lint errors (use `flake8`) +* [ ] No lint errors (use `ruff`) * [ ] Tests added diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4444955..a61a83e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,71 +1,55 @@ +# Copyright (C) 2025 Chris Caron +# 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 on: - - # On which repository actions to trigger the build. push: branches: [ master ] pull_request: branches: [ master ] - - # Allow job to be triggered manually. workflow_dispatch: -# Cancel in-progress jobs when pushing to the same branch. -concurrency: - cancel-in-progress: true - group: ${{ github.workflow }}-${{ github.ref }} - jobs: - 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: - - name: Acquire sources uses: actions/checkout@v4 - - name: Install prerequisites (Linux) - if: runner.os == 'Linux' + - name: Install prerequisites run: | sudo apt-get update - - name: Install project dependencies (Baseline) + - name: Install dependencies run: | pip install -r requirements.txt -r dev-requirements.txt - # For saving resources, code style checking is - # only invoked within the `bare` environment. - - name: Check code style - if: matrix.bare == true + - name: Lint with Ruff run: | - flake8 apprise_api --count --show-source --statistics + ruff check apprise_api - - name: Run tests + - name: Run tests with coverage run: | coverage run -m pytest apprise_api @@ -74,9 +58,10 @@ jobs: coverage xml coverage report - - name: Upload coverage data + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: files: ./coverage.xml fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} + diff --git a/.gitignore b/.gitignore index 4846909..d1773ff 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dist/ downloads/ eggs/ .eggs/ +.local/ lib64 lib var/ diff --git a/Dockerfile.py312 b/Dockerfile.py312 index 92b7a20..46cce12 100644 --- a/Dockerfile.py312 +++ b/Dockerfile.py312 @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise-API - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2025, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 7eb4d67..8aa1c56 100644 --- a/README.md +++ b/README.md @@ -616,33 +616,67 @@ spec: ``` ## 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 -# Create a virtual environment in the same directory you -# cloned this repository to: -python -m venv . +# Create and activate a Python 3.12 virtual environment: +python3.12 -m venv .venv +. .venv/bin/activate -# Activate it now: -. ./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 +# Install core dependencies: +pip install -e '.[dev]' ``` -Some other useful development notes: +### Running the Dev Server ```bash -# Check for any lint errors -flake8 apprise_api +# Start the development server in debug mode: +./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 +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 + +# Lint code with Ruff +ruff check . + +# Format code with Ruff +ruff format . ``` ## Apprise Integration @@ -790,4 +824,6 @@ The colon `:` prefix is the switch that starts the re-mapping rule engine. You ## Metrics Collection & Analysis + Basic Prometheus support added through `/metrics` reference point. + diff --git a/apprise_api/api/apps.py b/apprise_api/api/apps.py index 89316ef..8550c8b 100644 --- a/apprise_api/api/apps.py +++ b/apprise_api/api/apps.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -26,4 +25,4 @@ from django.apps import AppConfig class ApiConfig(AppConfig): - name = 'api' + name = "api" diff --git a/apprise_api/api/context_processors.py b/apprise_api/api/context_processors.py index cd10c2b..99e4456 100644 --- a/apprise_api/api/context_processors.py +++ b/apprise_api/api/context_processors.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2020 Chris Caron # All rights reserved. @@ -22,49 +21,50 @@ # 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 .utils import gen_unique_config_id -from .utils import ConfigCache from django.conf import settings + import apprise +from .utils import ConfigCache, gen_unique_config_id + def stateful_mode(request): """ Returns our loaded Stateful Mode """ - return {'STATEFUL_MODE': ConfigCache.mode} + return {"STATEFUL_MODE": ConfigCache.mode} def config_lock(request): """ 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): """ 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): """ 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): """ 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): """ Returns a unique config identifier """ - return {'UNIQUE_CONFIG_ID': gen_unique_config_id()} + return {"UNIQUE_CONFIG_ID": gen_unique_config_id()} diff --git a/apprise_api/api/forms.py b/apprise_api/api/forms.py index 4844395..1651dc6 100644 --- a/apprise_api/api/forms.py +++ b/apprise_api/api/forms.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -23,39 +22,39 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import apprise from django import forms from django.utils.translation import gettext_lazy as _ +import apprise + # Auto-Detect Keyword -AUTO_DETECT_CONFIG_KEYWORD = 'auto' +AUTO_DETECT_CONFIG_KEYWORD = "auto" # Define our potential configuration types CONFIG_FORMATS = ( - (AUTO_DETECT_CONFIG_KEYWORD, _('Auto-Detect')), - (apprise.ConfigFormat.TEXT, _('TEXT')), - (apprise.ConfigFormat.YAML, _('YAML')), + (AUTO_DETECT_CONFIG_KEYWORD, _("Auto-Detect")), + (apprise.ConfigFormat.TEXT.value, _("TEXT")), + (apprise.ConfigFormat.YAML.value, _("YAML")), ) NOTIFICATION_TYPES = ( - (apprise.NotifyType.INFO, _('Info')), - (apprise.NotifyType.SUCCESS, _('Success')), - (apprise.NotifyType.WARNING, _('Warning')), - (apprise.NotifyType.FAILURE, _('Failure')), + (apprise.NotifyType.INFO.value, _("Info")), + (apprise.NotifyType.SUCCESS.value, _("Success")), + (apprise.NotifyType.WARNING.value, _("Warning")), + (apprise.NotifyType.FAILURE.value, _("Failure")), ) # Define our potential input text categories INPUT_FORMATS = ( - (apprise.NotifyFormat.TEXT, _('TEXT')), - (apprise.NotifyFormat.MARKDOWN, _('MARKDOWN')), - (apprise.NotifyFormat.HTML, _('HTML')), + (apprise.NotifyFormat.TEXT.value, _("TEXT")), + (apprise.NotifyFormat.MARKDOWN.value, _("MARKDOWN")), + (apprise.NotifyFormat.HTML.value, _("HTML")), # As-is - do not interpret it - (None, _('IGNORE')), + (None, _("IGNORE")), ) URLS_MAX_LEN = 1024 -URLS_PLACEHOLDER = 'mailto://user:pass@domain.com, ' \ - 'slack://tokena/tokenb/tokenc, ...' +URLS_PLACEHOLDER = "mailto://user:pass@domain.com, slack://tokena/tokenb/tokenc, ..." class AddByUrlForm(forms.Form): @@ -66,9 +65,10 @@ class AddByUrlForm(forms.Form): This content can just be directly fed straight into Apprise """ + urls = forms.CharField( - label=_('URLs'), - widget=forms.TextInput(attrs={'placeholder': URLS_PLACEHOLDER}), + label=_("URLs"), + widget=forms.TextInput(attrs={"placeholder": URLS_PLACEHOLDER}), max_length=URLS_MAX_LEN, ) @@ -80,14 +80,14 @@ class AddByConfigForm(forms.Form): """ format = forms.ChoiceField( - label=_('Format'), + label=_("Format"), choices=CONFIG_FORMATS, initial=CONFIG_FORMATS[0][0], required=False, ) config = forms.CharField( - label=_('Configuration'), + label=_("Configuration"), widget=forms.Textarea(), max_length=4096, required=False, @@ -97,7 +97,7 @@ class AddByConfigForm(forms.Form): """ 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: # Set to auto data = CONFIG_FORMATS[0][0] @@ -111,44 +111,42 @@ class NotifyForm(forms.Form): """ format = forms.ChoiceField( - label=_('Process As'), + label=_("Process As"), initial=INPUT_FORMATS[0][0], choices=INPUT_FORMATS, required=False, ) type = forms.ChoiceField( - label=_('Type'), + label=_("Type"), choices=NOTIFICATION_TYPES, initial=NOTIFICATION_TYPES[0][0], required=False, ) title = forms.CharField( - label=_('Title'), - widget=forms.TextInput(attrs={'placeholder': _('Optional Title')}), + label=_("Title"), + widget=forms.TextInput(attrs={"placeholder": _("Optional Title")}), max_length=apprise.NotifyBase.title_maxlen, required=False, ) body = forms.CharField( - label=_('Body'), - widget=forms.Textarea( - attrs={'placeholder': _('Define your message body here...')}), + label=_("Body"), + widget=forms.Textarea(attrs={"placeholder": _("Define your message body here...")}), max_length=apprise.NotifyBase.body_maxlen, required=False, ) # Attachment Support attachment = forms.FileField( - label=_('Attachment'), + label=_("Attachment"), required=False, ) tag = forms.CharField( - label=_('Tags'), - widget=forms.TextInput( - attrs={'placeholder': _('Optional_Tag1, Optional_Tag2, ...')}), + label=_("Tags"), + widget=forms.TextInput(attrs={"placeholder": _("Optional_Tag1, Optional_Tag2, ...")}), required=False, ) @@ -156,7 +154,7 @@ class NotifyForm(forms.Form): # always take priority over this however adding `tags` gives the user more # flexibilty to use either/or keyword tags = forms.CharField( - label=_('Tags'), + label=_("Tags"), widget=forms.HiddenInput(), required=False, ) @@ -165,20 +163,20 @@ class NotifyForm(forms.Form): """ We just ensure there is a type always set """ - data = self.cleaned_data['type'] + data = self.cleaned_data["type"] if not data: # Always set a type - data = apprise.NotifyType.INFO + data = apprise.NotifyType.INFO.value return data def clean_format(self): """ We just ensure there is a format always set """ - data = self.cleaned_data['format'] + data = self.cleaned_data["format"] if not data: # Always set a type - data = apprise.NotifyFormat.TEXT + data = apprise.NotifyFormat.TEXT.value return data @@ -187,9 +185,10 @@ class NotifyByUrlForm(NotifyForm): Same as the NotifyForm but additionally processes a string of URLs to notify directly. """ + urls = forms.CharField( - label=_('URLs'), - widget=forms.TextInput(attrs={'placeholder': URLS_PLACEHOLDER}), + label=_("URLs"), + widget=forms.TextInput(attrs={"placeholder": URLS_PLACEHOLDER}), max_length=URLS_MAX_LEN, required=False, ) diff --git a/apprise_api/api/management/commands/storeprune.py b/apprise_api/api/management/commands/storeprune.py index b865e6e..6316815 100644 --- a/apprise_api/api/management/commands/storeprune.py +++ b/apprise_api/api/management/commands/storeprune.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2023 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # 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 # THE SOFTWARE. -from django.core.management.base import BaseCommand from django.conf import settings +from django.core.management.base import BaseCommand + import apprise @@ -32,14 +32,18 @@ class Command(BaseCommand): help = f"Prune all persistent content older then {settings.APPRISE_STORAGE_PRUNE_DAYS} days()" 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): # Persistent Storage cleanup apprise.PersistentStore.disk_prune( path=settings.APPRISE_STORAGE_DIR, - expires=options["days"] * 86400, action=True, - ) - self.stdout.write( - self.style.SUCCESS('Successfully pruned persistent storeage (days: %d)' % options["days"]) + expires=options["days"] * 86400, + action=True, ) + self.stdout.write(self.style.SUCCESS(f"Successfully pruned persistent storeage (days: {options['days']})")) diff --git a/apprise_api/api/payload_mapper.py b/apprise_api/api/payload_mapper.py index 489662b..58f8207 100644 --- a/apprise_api/api/payload_mapper.py +++ b/apprise_api/api/payload_mapper.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2024 Chris Caron # All rights reserved. @@ -22,13 +21,13 @@ # 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 api.forms import NotifyForm - # import the logging library import logging +from api.forms import NotifyForm + # Get an instance of a logger -logger = logging.getLogger('django') +logger = logging.getLogger("django") 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 expected_keys = set(form.fields.keys()) for _key, value in rules.items(): - key = _key.lower() if key in payload and not value: # Remove element diff --git a/apprise_api/api/tests/test_add.py b/apprise_api/api/tests/test_add.py index ef16e7f..888f3d2 100644 --- a/apprise_api/api/tests/test_add.py +++ b/apprise_api/api/tests/test_add.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,24 +21,26 @@ # 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.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 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): - def test_add_invalid_key_status_code(self): """ 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 def test_key_lengths(self): @@ -49,22 +50,22 @@ class AddTests(SimpleTestCase): # our key to use h = hashlib.sha512() - h.update(b'string') + h.update(b"string") key = h.hexdigest() # Our limit assert len(key) == 128 # Add our URL - response = self.client.post( - '/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # However adding just 1 more character exceeds our limit and the save # will fail response = self.client.post( - '/add/{}'.format(key + 'x'), - {'urls': 'mailto://user:pass@yahoo.ca'}) + "/add/{}".format(key + "x"), + {"urls": "mailto://user:pass@yahoo.ca"}, + ) assert response.status_code == 404 @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 """ # 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 - response = self.client.post( - '/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 403 def test_save_config_by_urls(self): @@ -86,68 +86,68 @@ class AddTests(SimpleTestCase): """ # our key to use - key = 'test_save_config_by_urls' + key = "test_save_config_by_urls" # GET returns 405 (not allowed) - response = self.client.get('/add/{}'.format(key)) + response = self.client.get("/add/{}".format(key)) assert response.status_code == 405 # no data - response = self.client.post('/add/{}'.format(key)) + response = self.client.post("/add/{}".format(key)) assert response.status_code == 400 # No entries specified - response = self.client.post( - '/add/{}'.format(key), {'urls': ''}) + response = self.client.post("/add/{}".format(key), {"urls": ""}) assert response.status_code == 400 # Added successfully - response = self.client.post( - '/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # No URLs loaded response = self.client.post( - '/add/{}'.format(key), - {'config': 'invalid content', 'format': 'text'}) + "/add/{}".format(key), + {"config": "invalid content", "format": "text"}, + ) assert response.status_code == 400 # 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( - '/add/{}'.format(key), - {'config': 'garbage://', 'format': 'text'}) + "/add/{}".format(key), + {"config": "garbage://", "format": "text"}, + ) 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 # one; This will result in a 500 error response = self.client.post( - '/add/{}'.format(key), { - 'urls': 'mailto://user:newpass@gmail.com'}) + "/add/{}".format(key), + {"urls": "mailto://user:newpass@gmail.com"}, + ) assert response.status_code == 500 # URL is actually not a valid one (invalid Slack tokens specified # below) - response = self.client.post( - '/add/{}'.format(key), {'urls': 'slack://-/-/-'}) + response = self.client.post("/add/{}".format(key), {"urls": "slack://-/-/-"}) assert response.status_code == 400 # Test with JSON response = self.client.post( - '/add/{}'.format(key), - data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}), + content_type="application/json", ) 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() # Send our notification by specifying the tag in the parameters response = self.client.post( - '/add/{}'.format(key), - data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}), + content_type="application/json", ) # Our notification failed @@ -155,49 +155,49 @@ class AddTests(SimpleTestCase): # Test with JSON (and no payload provided) response = self.client.post( - '/add/{}'.format(key), + "/add/{}".format(key), data=json.dumps({}), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 # Test with XML which simply isn't supported response = self.client.post( - '/add/{}'.format(key), - data='mailto://user:pass@yahoo.ca', - content_type='application/xml', + "/add/{}".format(key), + data="mailto://user:pass@yahoo.ca", + content_type="application/xml", ) assert response.status_code == 400 # Invalid JSON response = self.client.post( - '/add/{}'.format(key), - data='{', - content_type='application/json', + "/add/{}".format(key), + data="{", + content_type="application/json", ) assert response.status_code == 400 # 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() # We'll fail to write our key now response = self.client.post( - '/add/{}'.format(key), - data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}), + content_type="application/json", ) # internal errors are correctly identified assert response.status_code == 500 # 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() # We'll fail to write our key now response = self.client.post( - '/add/{}'.format(key), - data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}), + content_type="application/json", ) # internal errors are correctly identified @@ -209,15 +209,16 @@ class AddTests(SimpleTestCase): """ # our key to use - key = 'test_save_config_by_config' + key = "test_save_config_by_config" # Empty Text Configuration config = """ - """ # noqa W293 + """ response = self.client.post( - '/add/{}'.format(key), { - 'format': ConfigFormat.TEXT, 'config': config}) + "/add/{}".format(key), + {"format": ConfigFormat.TEXT.value, "config": config}, + ) assert response.status_code == 400 # Valid Text Configuration @@ -226,15 +227,16 @@ class AddTests(SimpleTestCase): home=mailto://user:pass@hotmail.com """ response = self.client.post( - '/add/{}'.format(key), - {'format': ConfigFormat.TEXT, 'config': config}) + "/add/{}".format(key), + {"format": ConfigFormat.TEXT.value, "config": config}, + ) assert response.status_code == 200 # Test with JSON response = self.client.post( - '/add/{}'.format(key), - data=json.dumps({'format': ConfigFormat.TEXT, 'config': config}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"format": ConfigFormat.TEXT.value, "config": config}), + content_type="application/json", ) assert response.status_code == 200 @@ -247,35 +249,35 @@ class AddTests(SimpleTestCase): tag: home """ response = self.client.post( - '/add/{}'.format(key), - {'format': ConfigFormat.YAML, 'config': config}) + "/add/{}".format(key), + {"format": ConfigFormat.YAML.value, "config": config}, + ) assert response.status_code == 200 # Test with JSON response = self.client.post( - '/add/{}'.format(key), - data=json.dumps({'format': ConfigFormat.YAML, 'config': config}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"format": ConfigFormat.YAML.value, "config": config}), + content_type="application/json", ) assert response.status_code == 200 # Test invalid config format response = self.client.post( - '/add/{}'.format(key), - data=json.dumps({'format': 'INVALID', 'config': config}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"format": "INVALID", "config": config}), + content_type="application/json", ) assert response.status_code == 400 # 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() # We'll fail to write our key now response = self.client.post( - '/add/{}'.format(key), - data=json.dumps( - {'format': ConfigFormat.YAML, 'config': config}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"format": ConfigFormat.YAML.value, "config": config}), + content_type="application/json", ) # internal errors are correctly identified @@ -287,15 +289,16 @@ class AddTests(SimpleTestCase): """ # our key to use - key = 'test_save_auto_detect_config_format' + key = "test_save_auto_detect_config_format" # Empty Text Configuration config = """ - """ # noqa W293 + """ response = self.client.post( - '/add/{}'.format(key), { - 'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}) + "/add/{}".format(key), + {"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}, + ) assert response.status_code == 400 # Valid Text Configuration @@ -304,15 +307,16 @@ class AddTests(SimpleTestCase): home=mailto://user:pass@hotmail.com """ response = self.client.post( - '/add/{}'.format(key), - {'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}) + "/add/{}".format(key), + {"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}, + ) assert response.status_code == 200 # Test with JSON response = self.client.post( - '/add/{}'.format(key), - data=json.dumps({'format': ConfigFormat.TEXT, 'config': config}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"format": ConfigFormat.TEXT.value, "config": config}), + content_type="application/json", ) assert response.status_code == 200 @@ -326,16 +330,16 @@ class AddTests(SimpleTestCase): tag: home """ response = self.client.post( - '/add/{}'.format(key), - {'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}) + "/add/{}".format(key), + {"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}, + ) assert response.status_code == 200 # Test with JSON response = self.client.post( - '/add/{}'.format(key), - data=json.dumps( - {'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}), + content_type="application/json", ) assert response.status_code == 200 @@ -344,16 +348,16 @@ class AddTests(SimpleTestCase): 42 """ response = self.client.post( - '/add/{}'.format(key), - {'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}) + "/add/{}".format(key), + {"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}, + ) assert response.status_code == 400 # Test with JSON response = self.client.post( - '/add/{}'.format(key), - data=json.dumps( - {'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}), + content_type="application/json", ) assert response.status_code == 400 @@ -363,11 +367,11 @@ class AddTests(SimpleTestCase): """ # our key to use - key = 'test_save_with_bad_input' + key = "test_save_with_bad_input" # Test with JSON response = self.client.post( - '/add/{}'.format(key), - data=json.dumps({'garbage': 'input'}), - content_type='application/json', + "/add/{}".format(key), + data=json.dumps({"garbage": "input"}), + content_type="application/json", ) assert response.status_code == 400 diff --git a/apprise_api/api/tests/test_attachment.py b/apprise_api/api/tests/test_attachment.py index 10962ce..b826589 100644 --- a/apprise_api/api/tests/test_attachment.py +++ b/apprise_api/api/tests/test_attachment.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,24 +21,25 @@ # 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. -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 -from unittest.mock import patch -from django.core.files.uploadedfile import SimpleUploadedFile -from os.path import dirname, join, getsize +from contextlib import suppress +from os.path import dirname, getsize, join +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): @@ -48,12 +48,9 @@ class AttachmentTests(SimpleTestCase): self.tmp_dir = TemporaryDirectory() def tearDown(self): - # Clear directory - try: + # Clear content if possible + with suppress(FileNotFoundError): rmtree(self.tmp_dir.name) - except FileNotFoundError: - # no worries - pass self.tmp_dir = None @@ -63,32 +60,32 @@ class AttachmentTests(SimpleTestCase): """ 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): - Attachment('file') + Attachment("file") 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): - Attachment('file') + Attachment("file") with self.assertRaises(ValueError): - HTTPAttachment('web') + HTTPAttachment("web") - with mock.patch('os.remove', side_effect=FileNotFoundError): - a = Attachment('file') + with mock.patch("os.remove", side_effect=FileNotFoundError): + a = Attachment("file") # Force __del__ call to throw an exception which we gracefully # handle del a - a = HTTPAttachment('web') - a._path = 'abcd' - assert a.filename == 'web' + a = HTTPAttachment("web") + a._path = "abcd" + assert a.filename == "web" # Force __del__ call to throw an exception which we gracefully # handle del a - a = Attachment('file') + a = Attachment("file") assert a.filename def test_form_file_attachment_parsing(self): @@ -114,19 +111,13 @@ class AttachmentTests(SimpleTestCase): assert len(result) == 0 # Get ourselves a file to work with - files_request = { - 'file1': SimpleUploadedFile( - "attach.txt", b"content here", content_type="text/plain") - } + files_request = {"file1": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")} result = parse_attachments(None, files_request) assert isinstance(result, list) assert len(result) == 1 # Test case where no filename was specified - files_request = { - 'file1': SimpleUploadedFile( - " ", b"content here", content_type="text/plain") - } + files_request = {"file1": SimpleUploadedFile(" ", b"content here", content_type="text/plain")} result = parse_attachments(None, files_request) assert isinstance(result, list) assert len(result) == 1 @@ -135,19 +126,19 @@ class AttachmentTests(SimpleTestCase): # attachment to disk m = mock_open() m.side_effect = OSError() - with patch('builtins.open', m): - with self.assertRaises(ValueError): - parse_attachments(None, files_request) + with patch("builtins.open", m), self.assertRaises(ValueError): + parse_attachments(None, files_request) # Test a case where our attachment exceeds the maximum size we allow # for with override_settings(APPRISE_ATTACH_SIZE=1): files_request = { - 'file1': SimpleUploadedFile( + "file1": SimpleUploadedFile( "attach.txt", # More then 1 MB in size causing error to trip - ("content" * 1024 * 1024).encode('utf-8'), - content_type="text/plain") + ("content" * 1024 * 1024).encode("utf-8"), + content_type="text/plain", + ) } with self.assertRaises(ValueError): parse_attachments(None, files_request) @@ -155,24 +146,22 @@ class AttachmentTests(SimpleTestCase): # Test Attachment Size seto t zer0 with override_settings(APPRISE_ATTACH_SIZE=0): files_request = { - 'file1': SimpleUploadedFile( + "file1": SimpleUploadedFile( "attach.txt", # More then 1 MB in size causing error to trip - ("content" * 1024 * 1024).encode('utf-8'), - content_type="text/plain") + ("content" * 1024 * 1024).encode("utf-8"), + content_type="text/plain", + ) } with self.assertRaises(ValueError): parse_attachments(None, files_request) # Bad data provided in filename field - files_request = { - 'file1': SimpleUploadedFile( - None, b"content here", content_type="text/plain") - } + files_request = {"file1": SimpleUploadedFile(None, b"content here", content_type="text/plain")} with self.assertRaises(ValueError): parse_attachments(None, files_request) - @patch('requests.get') + @patch("requests.get") def test_direct_attachment_parsing(self, mock_get): """ Test the parsing of file attachments @@ -187,33 +176,33 @@ class AttachmentTests(SimpleTestCase): response.status_code = requests.codes.ok response.raise_for_status.return_value = True response.headers = { - 'Content-Length': getsize(SAMPLE_FILE), + "Content-Length": getsize(SAMPLE_FILE), } ref = { - 'io': None, + "io": None, } def iter_content(chunk_size=1024, *args, **kwargs): - if not ref['io']: - ref['io'] = open(SAMPLE_FILE, 'rb') - block = ref['io'].read(chunk_size) + if not ref["io"]: + ref["io"] = open(SAMPLE_FILE, "rb") # noqa: SIM115 + block = ref["io"].read(chunk_size) if not block: # Close for re-use - ref['io'].close() - ref['io'] = None + ref["io"].close() + ref["io"] = None yield block + response.iter_content = iter_content def test(*args, **kwargs): return response + response.__enter__ = test response.__exit__ = test mock_get.return_value = response # Support base64 encoding - attachment_payload = { - 'base64': base64.b64encode(b'data to be encoded').decode('utf-8') - } + attachment_payload = {"base64": base64.b64encode(b"data to be encoded").decode("utf-8")} result = parse_attachments(attachment_payload, {}) assert isinstance(result, list) assert len(result) == 1 @@ -221,15 +210,14 @@ class AttachmentTests(SimpleTestCase): # Support multi entries attachment_payload = [ { - 'base64': base64.b64encode( - b'data to be encoded 1').decode('utf-8'), - }, { - 'base64': base64.b64encode( - b'data to be encoded 2').decode('utf-8'), - }, { - 'base64': base64.b64encode( - b'data to be encoded 3').decode('utf-8'), - } + "base64": base64.b64encode(b"data to be encoded 1").decode("utf-8"), + }, + { + "base64": base64.b64encode(b"data to be encoded 2").decode("utf-8"), + }, + { + "base64": base64.b64encode(b"data to be encoded 3").decode("utf-8"), + }, ] result = parse_attachments(attachment_payload, {}) assert isinstance(result, list) @@ -238,21 +226,24 @@ class AttachmentTests(SimpleTestCase): # Support multi entries attachment_payload = [ { - 'url': 'http://myserver/my.attachment.3', - }, { - 'url': 'http://myserver/my.attachment.2', - }, { - 'url': 'http://myserver/my.attachment.1', - } + "url": "http://myserver/my.attachment.3", + }, + { + "url": "http://myserver/my.attachment.2", + }, + { + "url": "http://myserver/my.attachment.1", + }, ] result = parse_attachments(attachment_payload, {}) assert isinstance(result, list) assert len(result) == 3 - with override_settings(APPRISE_ATTACH_DENY_URLS='*'): + with override_settings(APPRISE_ATTACH_DENY_URLS="*"): utils.ATTACH_URL_FILTER = AppriseURLFilter( settings.APPRISE_ATTACH_ALLOW_URLS, - settings.APPRISE_ATTACH_DENY_URLS) + settings.APPRISE_ATTACH_DENY_URLS, + ) # We will fail to parse our URL based attachment with self.assertRaises(ValueError): @@ -261,7 +252,8 @@ class AttachmentTests(SimpleTestCase): # Reload our configuration to default values utils.ATTACH_URL_FILTER = AppriseURLFilter( settings.APPRISE_ATTACH_ALLOW_URLS, - settings.APPRISE_ATTACH_DENY_URLS) + settings.APPRISE_ATTACH_DENY_URLS, + ) # Garbage handling (integer, float, object, etc is invalid) attachment_payload = 5 @@ -279,8 +271,8 @@ class AttachmentTests(SimpleTestCase): # filename provided, but its empty (and/or contains whitespace) attachment_payload = { - 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), - 'filename': ' ' + "base64": base64.b64encode(b"data to be encoded").decode("utf-8"), + "filename": " ", } result = parse_attachments(attachment_payload, {}) assert isinstance(result, list) @@ -288,30 +280,30 @@ class AttachmentTests(SimpleTestCase): # filename too long attachment_payload = { - 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), - 'filename': 'a' * 1000, + "base64": base64.b64encode(b"data to be encoded").decode("utf-8"), + "filename": "a" * 1000, } with self.assertRaises(ValueError): parse_attachments(attachment_payload, {}) # filename invalid attachment_payload = { - 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), - 'filename': 1, + "base64": base64.b64encode(b"data to be encoded").decode("utf-8"), + "filename": 1, } with self.assertRaises(ValueError): parse_attachments(attachment_payload, {}) attachment_payload = { - 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), - 'filename': None, + "base64": base64.b64encode(b"data to be encoded").decode("utf-8"), + "filename": None, } with self.assertRaises(ValueError): parse_attachments(attachment_payload, {}) attachment_payload = { - 'base64': base64.b64encode(b'data to be encoded').decode('utf-8'), - 'filename': object(), + "base64": base64.b64encode(b"data to be encoded").decode("utf-8"), + "filename": object(), } with self.assertRaises(ValueError): parse_attachments(attachment_payload, {}) @@ -332,19 +324,18 @@ class AttachmentTests(SimpleTestCase): # We allow empty entries, this is okay; there is just nothing # 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 # though they are attachment_payload = { - 'base64': 'not-base-64', + "base64": "not-base-64", } with self.assertRaises(ValueError): parse_attachments(attachment_payload, {}) # Support string; these become web requests - attachment_payload = \ - "https://avatars.githubusercontent.com/u/850374?v=4" + attachment_payload = "https://avatars.githubusercontent.com/u/850374?v=4" result = parse_attachments(attachment_payload, {}) assert isinstance(result, list) assert len(result) == 1 @@ -364,17 +355,15 @@ class AttachmentTests(SimpleTestCase): # to disk m = mock_open() m.side_effect = OSError() - with patch('builtins.open', m): - with self.assertRaises(ValueError): - attachment_payload = b"some data to work with." - parse_attachments(attachment_payload, {}) + with patch("builtins.open", m), self.assertRaises(ValueError): + attachment_payload = b"some data to work with." + parse_attachments(attachment_payload, {}) # Test a case where our attachment exceeds the maximum size we allow # for with override_settings(APPRISE_ATTACH_SIZE=1): # More then 1 MB in size causing error to trip - attachment_payload = \ - ("content" * 1024 * 1024).encode('utf-8') + attachment_payload = ("content" * 1024 * 1024).encode("utf-8") with self.assertRaises(ValueError): parse_attachments(attachment_payload, {}) @@ -387,7 +376,7 @@ class AttachmentTests(SimpleTestCase): attachment_payload = [ # Request several images "https://myserver/myotherfile.png", - "https://myserver/myfile.png" + "https://myserver/myfile.png", ] result = parse_attachments(attachment_payload, {}) assert isinstance(result, list) @@ -411,12 +400,13 @@ class AttachmentTests(SimpleTestCase): # We have hosts that will be blocked parse_attachments([ap], {}) - attachment_payload = [{ - # Request several images - 'url': "https://myserver/myotherfile.png", - }, { - 'url': "https://myserver/myfile.png" - }] + attachment_payload = [ + { + # Request several images + "url": "https://myserver/myotherfile.png", + }, + {"url": "https://myserver/myfile.png"}, + ] result = parse_attachments(attachment_payload, {}) assert isinstance(result, list) assert len(result) == 2 @@ -447,10 +437,13 @@ class AttachmentTests(SimpleTestCase): parse_attachments(attachment_payload, {}) # Support url encoding - attachment_payload = [{ - 'url': "https://myserver/garbage/abcd1.png", - }, { - 'url': "https://myserver/garbage/abcd2.png", - }] + attachment_payload = [ + { + "url": "https://myserver/garbage/abcd1.png", + }, + { + "url": "https://myserver/garbage/abcd2.png", + }, + ] with self.assertRaises(ValueError): parse_attachments(attachment_payload, {}) diff --git a/apprise_api/api/tests/test_cli.py b/apprise_api/api/tests/test_cli.py index 4bc4cc0..c1e3c3a 100644 --- a/apprise_api/api/tests/test_cli.py +++ b/apprise_api/api/tests/test_cli.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2023 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -24,12 +23,12 @@ # THE SOFTWARE. import io -from django.test import SimpleTestCase + from django.core import management +from django.test import SimpleTestCase class CommandTests(SimpleTestCase): - def test_command_style(self): out = io.StringIO() - management.call_command('storeprune', days=40, stdout=out) + management.call_command("storeprune", days=40, stdout=out) diff --git a/apprise_api/api/tests/test_config_cache.py b/apprise_api/api/tests/test_config_cache.py index 5ba8391..d4a97c5 100644 --- a/apprise_api/api/tests/test_config_cache.py +++ b/apprise_api/api/tests/test_config_cache.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,38 +21,36 @@ # 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. -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 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): """ Test Apprise Config Disk Put/Get using HASH mode """ - content = 'mailto://test:pass@gmail.com' - key = 'test_apprise_config_io_hash' + content = "mailto://test:pass@gmail.com" + key = "test_apprise_config_io_hash" # Create our object to work with acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH) # 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 - 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 - with patch('gzip.open') as mock_gzopen: + with patch("gzip.open") as mock_gzopen: mock_gzopen.side_effect = OSError() # 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 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 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 - 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 - with patch('gzip.open') as mock_gzopen: + with patch("gzip.open") as mock_gzopen: mock_gzopen.side_effect = OSError() # We'll fail to read our key now 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 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) # OSError assert acc_obj.clear(key) is False # If we try to put the same file, we'll fail since # 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 content = """ @@ -100,17 +97,17 @@ def test_apprise_config_io_hash_mode(tmpdir): # Write our content assigned to our key # This should gracefully clear the TEXT entry that was # 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 contents = os.listdir(conf_dir) # There should STILL be just 1 new file in this directory 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 - 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): @@ -121,26 +118,26 @@ def test_apprise_config_list_simple_mode(tmpdir): acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE) # Add a hidden file to the config directory (which should be ignored) - hidden_file = os.path.join(str(tmpdir), '.hidden') - with open(hidden_file, 'w') as f: - f.write('hidden file') + hidden_file = os.path.join(str(tmpdir), ".hidden") + with open(hidden_file, "w") as f: + f.write("hidden file") # Write 5 text configs and 5 yaml configs - content_text = 'mailto://test:pass@gmail.com' + content_text = "mailto://test:pass@gmail.com" content_yaml = """ version: 1 urls: - windows:// """ - text_key_tpl = 'test_apprise_config_list_simple_text_{}' - yaml_key_tpl = 'test_apprise_config_list_simple_yaml_{}' + text_key_tpl = "test_apprise_config_list_simple_text_{}" + yaml_key_tpl = "test_apprise_config_list_simple_yaml_{}" text_keys = [text_key_tpl.format(i) for i in range(5)] yaml_keys = [yaml_key_tpl.format(i) for i in range(5)] key = None 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: - 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 # contents of the directory @@ -161,26 +158,26 @@ def test_apprise_config_list_hash_mode(tmpdir): acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH) # Add a hidden file to the config directory (which should be ignored) - hidden_file = os.path.join(str(tmpdir), '.hidden') - with open(hidden_file, 'w') as f: - f.write('hidden file') + hidden_file = os.path.join(str(tmpdir), ".hidden") + with open(hidden_file, "w") as f: + f.write("hidden file") # Write 5 text configs and 5 yaml configs - content_text = 'mailto://test:pass@gmail.com' + content_text = "mailto://test:pass@gmail.com" content_yaml = """ version: 1 urls: - windows:// """ - text_key_tpl = 'test_apprise_config_list_simple_text_{}' - yaml_key_tpl = 'test_apprise_config_list_simple_yaml_{}' + text_key_tpl = "test_apprise_config_list_simple_text_{}" + yaml_key_tpl = "test_apprise_config_list_simple_yaml_{}" text_keys = [text_key_tpl.format(i) for i in range(5)] yaml_keys = [yaml_key_tpl.format(i) for i in range(5)] key = None 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: - 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 # 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 """ - content = 'mailto://test:pass@gmail.com' - key = 'test_apprise_config_io_simple' + content = "mailto://test:pass@gmail.com" + key = "test_apprise_config_io_simple" # Create our object to work with acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE) # 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 - assert acc_obj.put(key, content, ConfigFormat.TEXT) + assert acc_obj.put(key, content, ConfigFormat.TEXT.value) m = mock_open() m.side_effect = OSError() - with patch('builtins.open', m): + with patch("builtins.open", m): # 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 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 assert len(contents) == 1 - assert contents[0].endswith('.{}'.format(SimpleFileExtension.TEXT)) + assert contents[0].endswith(".{}".format(SimpleFileExtension.TEXT)) # 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 - with patch('builtins.open', m) as mock__open: + with patch("builtins.open", m) as mock__open: mock__open.side_effect = OSError() # We'll fail to read our key now 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 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) # OSError assert acc_obj.clear(key) is False # If we try to put the same file, we'll fail since # 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 content = """ @@ -260,25 +257,25 @@ def test_apprise_config_io_simple_mode(tmpdir): # Write our content assigned to our key # This should gracefully clear the TEXT entry that was # 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 contents = os.listdir(conf_dir) # There should STILL be just 1 new file in this directory assert len(contents) == 1 - assert contents[0].endswith('.{}'.format(SimpleFileExtension.YAML)) + assert contents[0].endswith(".{}".format(SimpleFileExtension.YAML)) # 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): """ Test Apprise Config Disk Put/Get using DISABLED mode """ - content = 'mailto://test:pass@gmail.com' - key = 'test_apprise_config_io_disabled' + content = "mailto://test:pass@gmail.com" + key = "test_apprise_config_io_disabled" # Create our object to work with using an invalid mode 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) # 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 # 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 conf_dir, _ = acc_obj.path(key) diff --git a/apprise_api/api/tests/test_config_middleware.py b/apprise_api/api/tests/test_config_middleware.py new file mode 100644 index 0000000..e9cb7da --- /dev/null +++ b/apprise_api/api/tests/test_config_middleware.py @@ -0,0 +1,52 @@ +# Copyright (C) 2025 Chris Caron +# 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/ + 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) diff --git a/apprise_api/api/tests/test_del.py b/apprise_api/api/tests/test_del.py index 2e45427..8f58d25 100644 --- a/apprise_api/api/tests/test_del.py +++ b/apprise_api/api/tests/test_del.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,19 +21,19 @@ # 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. +import hashlib +from unittest.mock import patch + from django.test import SimpleTestCase from django.test.utils import override_settings -from unittest.mock import patch -import hashlib class DelTests(SimpleTestCase): - def test_del_get_invalid_key_status_code(self): """ 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 def test_key_lengths(self): @@ -44,27 +43,26 @@ class DelTests(SimpleTestCase): # our key to use h = hashlib.sha512() - h.update(b'string') + h.update(b"string") key = h.hexdigest() # Our limit assert len(key) == 128 # Add our URL - response = self.client.post( - '/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # 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 # remove the key - response = self.client.post('/del/{}'.format(key)) + response = self.client.post("/del/{}".format(key)) assert response.status_code == 200 # Test again; key is gone - response = self.client.post('/del/{}'.format(key)) + response = self.client.post("/del/{}".format(key)) assert response.status_code == 204 @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 """ # our key to use - key = 'test_delete_with_lock' + key = "test_delete_with_lock" # 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 def test_del_post(self): @@ -84,31 +82,30 @@ class DelTests(SimpleTestCase): Test DEL POST """ # our key to use - key = 'test_delete' + key = "test_delete" # Invalid Key - response = self.client.post('/del/**invalid-key**') + response = self.client.post("/del/**invalid-key**") assert response.status_code == 404 # 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 # Add our key - response = self.client.post( - '/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # 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 - response = self.client.post('/del/{}'.format(key)) + response = self.client.post("/del/{}".format(key)) assert response.status_code == 500 # 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 # Key has already been removed - response = self.client.post('/del/{}'.format(key)) + response = self.client.post("/del/{}".format(key)) assert response.status_code == 204 diff --git a/apprise_api/api/tests/test_details.py b/apprise_api/api/tests/test_details.py index 2bea08e..06c9974 100644 --- a/apprise_api/api/tests/test_details.py +++ b/apprise_api/api/tests/test_details.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2023 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -26,12 +25,11 @@ from django.test import SimpleTestCase class DetailTests(SimpleTestCase): - def test_post_not_supported(self): """ Test POST requests """ - response = self.client.post('/details') + response = self.client.post("/details") # 405 as posting is not allowed assert response.status_code == 405 @@ -41,38 +39,46 @@ class DetailTests(SimpleTestCase): """ # Nothing to return - response = self.client.get('/details') + response = self.client.get("/details") self.assertEqual(response.status_code, 200) - assert response['Content-Type'].startswith('text/html') + assert response["Content-Type"].startswith("text/html") # JSON Response response = self.client.get( - '/details', content_type='application/json', - **{'HTTP_CONTENT_TYPE': 'application/json'}) + "/details", + content_type="application/json", + **{"HTTP_CONTENT_TYPE": "application/json"}, + ) self.assertEqual(response.status_code, 200) - assert response['Content-Type'].startswith('application/json') + assert response["Content-Type"].startswith("application/json") # JSON Response response = self.client.get( - '/details', content_type='application/json', - **{'HTTP_ACCEPT': 'application/json'}) + "/details", + content_type="application/json", + **{"HTTP_ACCEPT": "application/json"}, + ) 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) - assert response['Content-Type'].startswith('text/html') + assert response["Content-Type"].startswith("text/html") # JSON Response response = self.client.get( - '/details?all=yes', content_type='application/json', - **{'HTTP_CONTENT_TYPE': 'application/json'}) + "/details?all=yes", + content_type="application/json", + **{"HTTP_CONTENT_TYPE": "application/json"}, + ) self.assertEqual(response.status_code, 200) - assert response['Content-Type'].startswith('application/json') + assert response["Content-Type"].startswith("application/json") # JSON Response response = self.client.get( - '/details?all=yes', content_type='application/json', - **{'HTTP_ACCEPT': 'application/json'}) + "/details?all=yes", + content_type="application/json", + **{"HTTP_ACCEPT": "application/json"}, + ) self.assertEqual(response.status_code, 200) - assert response['Content-Type'].startswith('application/json') + assert response["Content-Type"].startswith("application/json") diff --git a/apprise_api/api/tests/test_get.py b/apprise_api/api/tests/test_get.py index 4fc945e..0c94048 100644 --- a/apprise_api/api/tests/test_get.py +++ b/apprise_api/api/tests/test_get.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,17 +21,17 @@ # 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.test import SimpleTestCase from unittest.mock import patch +from django.test import SimpleTestCase + class GetTests(SimpleTestCase): - def test_get_invalid_key_status_code(self): """ 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 def test_get_config(self): @@ -41,47 +40,48 @@ class GetTests(SimpleTestCase): """ # our key to use - key = 'test_get_config_' + key = "test_get_config_" # GET returns 405 (not allowed) - response = self.client.get('/get/{}'.format(key)) + response = self.client.get("/get/{}".format(key)) assert response.status_code == 405 # 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) # Add some content - response = self.client.post( - '/add/{}'.format(key), - {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # 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 # corruption here or someone meddling with the backend. - with patch('gzip.open', side_effect=OSError): - response = self.client.post('/get/{}'.format(key)) + with patch("gzip.open", side_effect=OSError): + response = self.client.post("/get/{}".format(key)) assert response.status_code == 500 # 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 # Add a YAML file response = self.client.post( - '/add/{}'.format(key), { - 'format': 'yaml', - 'config': """ + "/add/{}".format(key), + { + "format": "yaml", + "config": """ urls: - - dbus://"""}) + - dbus://""", + }, + ) assert response.status_code == 200 # Now retrieve our YAML configuration - response = self.client.post('/get/{}'.format(key)) + response = self.client.post("/get/{}".format(key)) assert response.status_code == 200 # Verify that the correct Content-Type is set in the header of the # response - assert 'Content-Type' in response - assert response['Content-Type'].startswith('text/yaml') + assert "Content-Type" in response + assert response["Content-Type"].startswith("text/yaml") diff --git a/apprise_api/api/tests/test_healthecheck.py b/apprise_api/api/tests/test_healthecheck.py index ade23f8..84084de 100644 --- a/apprise_api/api/tests/test_healthecheck.py +++ b/apprise_api/api/tests/test_healthecheck.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2024 Chris Caron # All rights reserved. @@ -22,20 +21,21 @@ # 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. -import mock -from django.test import SimpleTestCase from json import loads +from unittest import mock + +from django.test import SimpleTestCase from django.test.utils import override_settings + from ..utils import healthcheck class HealthCheckTests(SimpleTestCase): - def test_post_not_supported(self): """ Test POST requests """ - response = self.client.post('/status') + response = self.client.post("/status") # 405 as posting is not allowed assert response.status_code == 405 @@ -45,129 +45,139 @@ class HealthCheckTests(SimpleTestCase): """ # First Status Check - response = self.client.get('/status') + response = self.client.get("/status") self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, b'OK') - assert response['Content-Type'].startswith('text/plain') + self.assertEqual(response.content, b"OK") + assert response["Content-Type"].startswith("text/plain") # 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.content, b'OK') - assert response['Content-Type'].startswith('text/plain') + self.assertEqual(response.content, b"OK") + assert response["Content-Type"].startswith("text/plain") # JSON Response response = self.client.get( - '/status', content_type='application/json', - **{'HTTP_CONTENT_TYPE': 'application/json'}) + "/status", + content_type="application/json", + **{"HTTP_CONTENT_TYPE": "application/json"}, + ) self.assertEqual(response.status_code, 200) content = loads(response.content) assert content == { - 'config_lock': False, - 'attach_lock': False, - 'status': { - 'persistent_storage': True, - 'can_write_config': True, - 'can_write_attach': True, - 'details': ['OK'] - } + "config_lock": False, + "attach_lock": False, + "status": { + "persistent_storage": True, + "can_write_config": True, + "can_write_attach": True, + "details": ["OK"], + }, } - assert response['Content-Type'].startswith('application/json') + assert response["Content-Type"].startswith("application/json") with override_settings(APPRISE_CONFIG_LOCK=True): # Status Check (Form based) - response = self.client.get('/status') + response = self.client.get("/status") self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, b'OK') - assert response['Content-Type'].startswith('text/plain') + self.assertEqual(response.content, b"OK") + assert response["Content-Type"].startswith("text/plain") # JSON Response response = self.client.get( - '/status', content_type='application/json', - **{'HTTP_CONTENT_TYPE': 'application/json'}) + "/status", + content_type="application/json", + **{"HTTP_CONTENT_TYPE": "application/json"}, + ) self.assertEqual(response.status_code, 200) content = loads(response.content) assert content == { - 'config_lock': True, - 'attach_lock': False, - 'status': { - 'persistent_storage': True, - 'can_write_config': False, - 'can_write_attach': True, - 'details': ['OK'] - } + "config_lock": True, + "attach_lock": False, + "status": { + "persistent_storage": True, + "can_write_config": False, + "can_write_attach": True, + "details": ["OK"], + }, } - with override_settings(APPRISE_STATEFUL_MODE='disabled'): + with override_settings(APPRISE_STATEFUL_MODE="disabled"): # Status Check (Form based) - response = self.client.get('/status') + response = self.client.get("/status") self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, b'OK') - assert response['Content-Type'].startswith('text/plain') + self.assertEqual(response.content, b"OK") + assert response["Content-Type"].startswith("text/plain") # JSON Response response = self.client.get( - '/status', content_type='application/json', - **{'HTTP_CONTENT_TYPE': 'application/json'}) + "/status", + content_type="application/json", + **{"HTTP_CONTENT_TYPE": "application/json"}, + ) self.assertEqual(response.status_code, 200) content = loads(response.content) assert content == { - 'config_lock': False, - 'attach_lock': False, - 'status': { - 'persistent_storage': True, - 'can_write_config': False, - 'can_write_attach': True, - 'details': ['OK'] - } + "config_lock": False, + "attach_lock": False, + "status": { + "persistent_storage": True, + "can_write_config": False, + "can_write_attach": True, + "details": ["OK"], + }, } with override_settings(APPRISE_ATTACH_SIZE=0): # Status Check (Form based) - response = self.client.get('/status') + response = self.client.get("/status") self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, b'OK') - assert response['Content-Type'].startswith('text/plain') + self.assertEqual(response.content, b"OK") + assert response["Content-Type"].startswith("text/plain") # JSON Response response = self.client.get( - '/status', content_type='application/json', - **{'HTTP_CONTENT_TYPE': 'application/json'}) + "/status", + content_type="application/json", + **{"HTTP_CONTENT_TYPE": "application/json"}, + ) self.assertEqual(response.status_code, 200) content = loads(response.content) assert content == { - 'config_lock': False, - 'attach_lock': True, - 'status': { - 'persistent_storage': True, - 'can_write_config': True, - 'can_write_attach': False, - 'details': ['OK'] - } + "config_lock": False, + "attach_lock": True, + "status": { + "persistent_storage": True, + "can_write_config": True, + "can_write_attach": False, + "details": ["OK"], + }, } with override_settings(APPRISE_MAX_ATTACHMENTS=0): # Status Check (Form based) - response = self.client.get('/status') + response = self.client.get("/status") self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, b'OK') - assert response['Content-Type'].startswith('text/plain') + self.assertEqual(response.content, b"OK") + assert response["Content-Type"].startswith("text/plain") # JSON Response response = self.client.get( - '/status', content_type='application/json', - **{'HTTP_CONTENT_TYPE': 'application/json'}) + "/status", + content_type="application/json", + **{"HTTP_CONTENT_TYPE": "application/json"}, + ) self.assertEqual(response.status_code, 200) content = loads(response.content) assert content == { - 'config_lock': False, - 'attach_lock': False, - 'status': { - 'persistent_storage': True, - 'can_write_config': True, - 'can_write_attach': True, - 'details': ['OK'] - } + "config_lock": False, + "attach_lock": False, + "status": { + "persistent_storage": True, + "can_write_config": True, + "can_write_attach": True, + "details": ["OK"], + }, } def test_healthcheck_library(self): @@ -177,115 +187,129 @@ class HealthCheckTests(SimpleTestCase): result = healthcheck(lazy=True) assert result == { - 'persistent_storage': True, - 'can_write_config': True, - 'can_write_attach': True, - 'details': ['OK'] + "persistent_storage": True, + "can_write_config": True, + "can_write_attach": True, + "details": ["OK"], } # A Double lazy check result = healthcheck(lazy=True) assert result == { - 'persistent_storage': True, - 'can_write_config': True, - 'can_write_attach': True, - 'details': ['OK'] + "persistent_storage": True, + "can_write_config": True, + "can_write_attach": True, + "details": ["OK"], } # 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() result = healthcheck(lazy=True) # We still succeed; we just don't leverage our lazy check # which prevents addition (unnessisary) writes assert result == { - 'persistent_storage': True, - 'can_write_config': True, - 'can_write_attach': True, - 'details': ['OK'], + "persistent_storage": True, + "can_write_config": True, + "can_write_attach": True, + "details": ["OK"], } # 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() result = healthcheck(lazy=True) # We still succeed; we just don't leverage our lazy check # which prevents addition (unnessisary) writes assert result == { - 'persistent_storage': True, - 'can_write_config': False, - 'can_write_attach': False, - 'details': [ - 'CONFIG_PERMISSION_ISSUE', - 'ATTACH_PERMISSION_ISSUE', - ]} + "persistent_storage": True, + "can_write_config": False, + "can_write_attach": False, + "details": [ + "CONFIG_PERMISSION_ISSUE", + "ATTACH_PERMISSION_ISSUE", + ], + } # 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() result = healthcheck(lazy=False) assert result == { - 'persistent_storage': False, - 'can_write_config': False, - 'can_write_attach': False, - 'details': [ - 'CONFIG_PERMISSION_ISSUE', - 'ATTACH_PERMISSION_ISSUE', - 'STORE_PERMISSION_ISSUE', - ]} + "persistent_storage": False, + "can_write_config": False, + "can_write_attach": False, + "details": [ + "CONFIG_PERMISSION_ISSUE", + "ATTACH_PERMISSION_ISSUE", + "STORE_PERMISSION_ISSUE", + ], + } - with mock.patch('os.path.getmtime') as mock_getmtime: - with mock.patch('os.fdopen', side_effect=OSError()): - mock_getmtime.side_effect = OSError() - 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): + with mock.patch("os.path.getmtime") as mock_getmtime, \ + mock.patch("os.fdopen", side_effect=OSError()): + mock_getmtime.side_effect = OSError() + mock_makedirs.side_effect = None result = healthcheck(lazy=False) assert result == { - 'persistent_storage': False, - 'can_write_config': True, - 'can_write_attach': True, - 'details': [ - 'STORE_PERMISSION_ISSUE', - ]} + "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) + 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 # health checks will always disable persistent storage - with override_settings(APPRISE_STORAGE_DIR=""): - 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': ['OK']} + with override_settings(APPRISE_STORAGE_DIR=""), \ + 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": ["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) assert result == { - 'persistent_storage': True, - 'can_write_config': False, - 'can_write_attach': False, - 'details': [ - 'CONFIG_PERMISSION_ISSUE', - 'ATTACH_PERMISSION_ISSUE', - ]} + "persistent_storage": True, + "can_write_config": False, + "can_write_attach": False, + "details": [ + "CONFIG_PERMISSION_ISSUE", + "ATTACH_PERMISSION_ISSUE", + ], + } mock_makedirs.side_effect = (OSError(), None, None, None, None) result = healthcheck(lazy=False) assert result == { - 'persistent_storage': True, - 'can_write_config': False, - 'can_write_attach': True, - 'details': [ - 'CONFIG_PERMISSION_ISSUE', - ]} + "persistent_storage": True, + "can_write_config": False, + "can_write_attach": True, + "details": [ + "CONFIG_PERMISSION_ISSUE", + ], + } diff --git a/apprise_api/api/tests/test_json_urls.py b/apprise_api/api/tests/test_json_urls.py index 471f5dc..0b3687f 100644 --- a/apprise_api/api/tests/test_json_urls.py +++ b/apprise_api/api/tests/test_json_urls.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,25 +21,25 @@ # 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 unittest.mock import patch + from django.test import SimpleTestCase from django.test.utils import override_settings -from unittest.mock import patch class JsonUrlsTests(SimpleTestCase): - def test_get_invalid_key_status_code(self): """ 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 def test_post_not_supported(self): """ 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 assert response.status_code == 405 @@ -50,111 +49,108 @@ class JsonUrlsTests(SimpleTestCase): """ # our key to use - key = 'test_json_urls_config' + key = "test_json_urls_config" # 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) # Add some content - response = self.client.post( - '/add/{}'.format(key), - {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # 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 # corruption here or someone meddling with the backend. - with patch('gzip.open', side_effect=OSError): - response = self.client.get('/json/urls/{}'.format(key)) + with patch("gzip.open", side_effect=OSError): + response = self.client.get("/json/urls/{}".format(key)) assert response.status_code == 500 - assert response['Content-Type'].startswith('application/json') - assert 'tags' in response.json() - assert 'urls' in response.json() + assert response["Content-Type"].startswith("application/json") + assert "tags" in response.json() + assert "urls" in response.json() # has error directive - assert 'error' in response.json() + assert "error" in response.json() # entries exist by are empty - assert len(response.json()['tags']) == 0 - assert len(response.json()['urls']) == 0 + assert len(response.json()["tags"]) == 0 + assert len(response.json()["urls"]) == 0 # 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['Content-Type'].startswith('application/json') - assert 'tags' in response.json() - assert 'urls' in response.json() + assert response["Content-Type"].startswith("application/json") + assert "tags" in response.json() + assert "urls" in response.json() # 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 - assert len(response.json()['tags']) == 0 + assert len(response.json()["tags"]) == 0 # 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['Content-Type'].startswith('application/json') - assert 'tags' in response.json() - assert 'urls' in response.json() + assert response["Content-Type"].startswith("application/json") + assert "tags" in response.json() + assert "urls" in response.json() # 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 - assert len(response.json()['tags']) == 0 + assert len(response.json()["tags"]) == 0 # One URL loaded - assert len(response.json()['urls']) == 1 - assert 'url' 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"]) == 1 + assert "url" in response.json()["urls"][0] + assert "tags" in response.json()["urls"][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 - without_privacy = \ - self.client.get('/json/urls/{}?privacy=1'.format(key)) - with_privacy = self.client.get('/json/urls/{}'.format(key)) - assert with_privacy.json()['urls'][0] != \ - without_privacy.json()['urls'][0] + without_privacy = self.client.get("/json/urls/{}?privacy=1".format(key)) + with_privacy = self.client.get("/json/urls/{}".format(key)) + assert with_privacy.json()["urls"][0] != without_privacy.json()["urls"][0] with override_settings(APPRISE_CONFIG_LOCK=True): # When our configuration lock is set, our result set enforces the # privacy flag even if it was otherwise set: - with_privacy = \ - self.client.get('/json/urls/{}?privacy=1'.format(key)) + with_privacy = self.client.get("/json/urls/{}?privacy=1".format(key)) # But now they're the same under this new condition - assert with_privacy.json()['urls'][0] == \ - without_privacy.json()['urls'][0] + assert with_privacy.json()["urls"][0] == without_privacy.json()["urls"][0] # Add a YAML file response = self.client.post( - '/add/{}'.format(key), { - 'format': 'yaml', - 'config': """ + "/add/{}".format(key), + { + "format": "yaml", + "config": """ urls: - dbus://: - - tag: tag1, tag2"""}) + - tag: tag1, tag2""", + }, + ) assert response.status_code == 200 # 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 # 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 - assert len(response.json()['tags']) == 2 + assert len(response.json()["tags"]) == 2 # One URL loaded - assert len(response.json()['urls']) == 1 - assert 'url' 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"]) == 1 + assert "url" in response.json()["urls"][0] + assert "tags" in response.json()["urls"][0] + assert len(response.json()["urls"][0]["tags"]) == 2 # Verify that the correct Content-Type is set in the header of the # response - assert 'Content-Type' in response - assert response['Content-Type'].startswith('application/json') + assert "Content-Type" in response + assert response["Content-Type"].startswith("application/json") diff --git a/apprise_api/api/tests/test_manager.py b/apprise_api/api/tests/test_manager.py index 223a2db..00c2009 100644 --- a/apprise_api/api/tests/test_manager.py +++ b/apprise_api/api/tests/test_manager.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,8 +21,7 @@ # 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.test import SimpleTestCase -from django.test import override_settings +from django.test import SimpleTestCase, override_settings class ManagerPageTests(SimpleTestCase): @@ -36,34 +34,34 @@ class ManagerPageTests(SimpleTestCase): General testing of management page """ # No permission to get keys - response = self.client.get('/cfg/') + response = self.client.get("/cfg/") assert response.status_code == 403 - with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='hash'): - response = self.client.get('/cfg/') + with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="hash"): + response = self.client.get("/cfg/") assert response.status_code == 403 - with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE='simple'): - response = self.client.get('/cfg/') + with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE="simple"): + response = self.client.get("/cfg/") assert response.status_code == 403 - with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE='disabled'): - response = self.client.get('/cfg/') + with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE="disabled"): + response = self.client.get("/cfg/") assert response.status_code == 403 - with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='disabled'): - response = self.client.get('/cfg/') + with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="disabled"): + response = self.client.get("/cfg/") assert response.status_code == 403 # But only when the setting is enabled - with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='simple'): - response = self.client.get('/cfg/') + with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="simple"): + response = self.client.get("/cfg/") assert response.status_code == 200 # An invalid key was specified - response = self.client.get('/cfg/**invalid-key**') + response = self.client.get("/cfg/**invalid-key**") assert response.status_code == 404 # An invalid key was specified - response = self.client.get('/cfg/valid-key') + response = self.client.get("/cfg/valid-key") assert response.status_code == 200 diff --git a/apprise_api/api/tests/test_notify.py b/apprise_api/api/tests/test_notify.py index 74ddeff..70c2c22 100644 --- a/apprise_api/api/tests/test_notify.py +++ b/apprise_api/api/tests/test_notify.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2019 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -22,15 +20,18 @@ # 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.test import SimpleTestCase, override_settings -from django.core.files.uploadedfile import SimpleUploadedFile -from django.core.exceptions import RequestDataTooBig -from unittest import mock -import requests -from ..forms import NotifyForm -import json -import apprise from inspect import cleandoc +import json +from unittest import mock + +from django.core.exceptions import RequestDataTooBig +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import SimpleTestCase, override_settings +import requests + +import apprise + +from ..forms import NotifyForm # Grant access to our Notification Manager Singleton N_MGR = apprise.manager_plugins.NotificationManager() @@ -41,7 +42,7 @@ class NotifyTests(SimpleTestCase): Test notifications """ - @mock.patch('apprise.Apprise.notify') + @mock.patch("apprise.Apprise.notify") def test_notify_by_loaded_urls(self, mock_notify): """ Test adding a simple notification and notifying it @@ -51,17 +52,15 @@ class NotifyTests(SimpleTestCase): mock_notify.return_value = True # our key to use - key = 'test_notify_by_loaded_urls' + key = "test_notify_by_loaded_urls" # Add some content - response = self.client.post( - '/add/{}'.format(key), - {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # Preare our form data form_data = { - 'body': 'test notifiction', + "body": "test notifiction", } # At a minimum, just a body is required @@ -69,17 +68,16 @@ class NotifyTests(SimpleTestCase): assert form.is_valid() # Required to prevent None from being passed into self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # we always set a type if one wasn't done so already - assert form.cleaned_data['type'] == apprise.NotifyType.INFO + assert form.cleaned_data["type"] == apprise.NotifyType.INFO.value # we always set a format if one wasn't done so already - assert form.cleaned_data['format'] == apprise.NotifyFormat.TEXT + assert form.cleaned_data["format"] == apprise.NotifyFormat.TEXT.value # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 assert mock_notify.call_count == 1 @@ -88,18 +86,14 @@ class NotifyTests(SimpleTestCase): # Preare our form data form_data = {} - attach_data = { - 'attachment': SimpleUploadedFile( - "attach.txt", b"content here", content_type="text/plain") - } + attach_data = {"attachment": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")} # At a minimum, just an attachment is required form = NotifyForm(form_data, attach_data) assert form.is_valid() # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 assert mock_notify.call_count == 1 @@ -108,20 +102,16 @@ class NotifyTests(SimpleTestCase): # Preare our form data form_data = { - 'body': 'test notifiction', - } - attach_data = { - 'attachment': SimpleUploadedFile( - "attach.txt", b"content here", content_type="text/plain") + "body": "test notifiction", } + attach_data = {"attachment": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")} # At a minimum, just a body is required form = NotifyForm(form_data, attach_data) assert form.is_valid() # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 assert mock_notify.call_count == 1 @@ -129,17 +119,20 @@ class NotifyTests(SimpleTestCase): mock_notify.reset_mock() # Test Headers - for level in ('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', - 'TRACE', 'INVALID'): - + for level in ( + "CRITICAL", + "ERROR", + "WARNING", + "INFO", + "DEBUG", + "TRACE", + "INVALID", + ): # Preare our form data form_data = { - 'body': 'test notifiction', - } - attach_data = { - 'attachment': SimpleUploadedFile( - "attach.txt", b"content here", content_type="text/plain") + "body": "test notifiction", } + attach_data = {"attachment": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")} # At a minimum, just a body is required form = NotifyForm(form_data, attach_data) @@ -147,12 +140,11 @@ class NotifyTests(SimpleTestCase): # Prepare our header headers = { - 'HTTP_X-APPRISE-LOG-LEVEL': level, + "HTTP_X-APPRISE-LOG-LEVEL": level, } # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data, **headers) + response = self.client.post("/notify/{}".format(key), form.cleaned_data, **headers) assert response.status_code == 200 assert mock_notify.call_count == 1 @@ -161,9 +153,11 @@ class NotifyTests(SimpleTestCase): # Long Filename attach_data = { - 'attachment': SimpleUploadedFile( - "{}.txt".format('a' * 2000), - b"content here", content_type="text/plain") + "attachment": SimpleUploadedFile( + "{}.txt".format("a" * 2000), + b"content here", + content_type="text/plain", + ) } # At a minimum, just a body is required @@ -171,8 +165,7 @@ class NotifyTests(SimpleTestCase): assert form.is_valid() # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) # We fail because the filename is too long assert response.status_code == 400 @@ -183,23 +176,18 @@ class NotifyTests(SimpleTestCase): # A setting of zero means unlimited attachments are allowed with override_settings(APPRISE_MAX_ATTACHMENTS=0): - # Preare our form data form_data = { - 'body': 'test notifiction', - } - attach_data = { - 'attachment': SimpleUploadedFile( - "attach.txt", b"content here", content_type="text/plain") + "body": "test notifiction", } + attach_data = {"attachment": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")} # At a minimum, just a body is required form = NotifyForm(form_data, attach_data) assert form.is_valid() # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) # We're good! assert response.status_code == 200 @@ -210,10 +198,9 @@ class NotifyTests(SimpleTestCase): # Only allow 1 attachment, but we'll attempt to send more... with override_settings(APPRISE_MAX_ATTACHMENTS=1): - # Preare our form data form_data = { - 'body': 'test notifiction', + "body": "test notifiction", } # At a minimum, just a body is required @@ -221,19 +208,20 @@ class NotifyTests(SimpleTestCase): assert form.is_valid() # Required to prevent None from being passed into self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] data = { **form.cleaned_data, - 'file1': SimpleUploadedFile( - "attach1.txt", b"content here", content_type="text/plain"), - 'file2': SimpleUploadedFile( - "attach2.txt", b"more content here", content_type="text/plain"), + "file1": SimpleUploadedFile("attach1.txt", b"content here", content_type="text/plain"), + "file2": SimpleUploadedFile( + "attach2.txt", + b"more content here", + content_type="text/plain", + ), } # Send our notification - response = self.client.post( - '/notify/{}'.format(key), data, format='multipart') + response = self.client.post("/notify/{}".format(key), data, format="multipart") # Too many attachments assert response.status_code == 400 @@ -244,23 +232,18 @@ class NotifyTests(SimpleTestCase): # A setting of zero means unlimited attachments are allowed with override_settings(APPRISE_ATTACH_SIZE=0): - # Preare our form data form_data = { - 'body': 'test notifiction', - } - attach_data = { - 'attachment': SimpleUploadedFile( - "attach.txt", b"content here", content_type="text/plain") + "body": "test notifiction", } + attach_data = {"attachment": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")} # At a minimum, just a body is required form = NotifyForm(form_data, attach_data) assert form.is_valid() # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) # No attachments allowed assert response.status_code == 400 @@ -270,18 +253,16 @@ class NotifyTests(SimpleTestCase): mock_notify.reset_mock() # Test Webhooks - with mock.patch('requests.post') as mock_post: + with mock.patch("requests.post") as mock_post: # Response object response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response - with override_settings( - APPRISE_WEBHOOK_URL='http://localhost/webhook/'): - + with override_settings(APPRISE_WEBHOOK_URL="http://localhost/webhook/"): # Preare our form data form_data = { - 'body': 'test notifiction', + "body": "test notifiction", } # At a minimum, just a body is required @@ -290,11 +271,10 @@ class NotifyTests(SimpleTestCase): # Required to prevent None from being passed into # self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) # Test our results assert response.status_code == 200 @@ -304,7 +284,7 @@ class NotifyTests(SimpleTestCase): # Reset our mock object mock_notify.reset_mock() - @mock.patch('requests.post') + @mock.patch("requests.post") def test_notify_with_tags(self, mock_post): """ Test notification handling when setting tags @@ -313,7 +293,7 @@ class NotifyTests(SimpleTestCase): # Disable Throttling to speed testing apprise.plugins.NotifyBase.request_rate_per_sec = 0 # Ensure we're enabled for the purpose of our testing - N_MGR['json'].enabled = True + N_MGR["json"].enabled = True # Prepare our response response = requests.Request() @@ -321,7 +301,7 @@ class NotifyTests(SimpleTestCase): mock_post.return_value = response # our key to use - key = 'test_notify_with_tags' + key = "test_notify_with_tags" # Valid Yaml Configuration config = """ @@ -331,21 +311,18 @@ class NotifyTests(SimpleTestCase): """ # Load our configuration (it will be detected as YAML) - response = self.client.post( - '/add/{}'.format(key), - {'config': config}) + response = self.client.post("/add/{}".format(key), {"config": config}) assert response.status_code == 200 # Preare our form data form_data = { - 'body': 'test notifiction', - 'type': apprise.NotifyType.INFO, - 'format': apprise.NotifyFormat.TEXT, + "body": "test notifiction", + "type": apprise.NotifyType.INFO.value, + "format": apprise.NotifyFormat.TEXT.value, } # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form_data) + response = self.client.post("/notify/{}".format(key), form_data) # Nothing could be notified as there were no tag matches assert response.status_code == 424 @@ -353,18 +330,17 @@ class NotifyTests(SimpleTestCase): # Now let's send our notification by specifying the tag in the # parameters - response = self.client.post( - '/notify/{}?tag=home'.format(key), form_data) + response = self.client.post("/notify/{}?tag=home".format(key), form_data) # Our notification was sent assert response.status_code == 200 assert mock_post.call_count == 1 # Test our posted data - response = json.loads(mock_post.call_args_list[0][1]['data']) - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[0][1]["data"]) + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Preare our form data (body is actually the minimum requirement) # All of the rest of the variables can actually be over-ridden @@ -372,25 +348,24 @@ class NotifyTests(SimpleTestCase): # payload). The Payload contents of the POST request always take # priority to eliminate any ambiguity form_data = { - 'body': 'test notifiction', + "body": "test notifiction", } # Reset our mock object mock_post.reset_mock() # tags keyword is also supported - response = self.client.post( - '/notify/{}?tags=home'.format(key), form_data) + response = self.client.post("/notify/{}?tags=home".format(key), form_data) # Our notification was sent assert response.status_code == 200 assert mock_post.call_count == 1 # Test our posted data - response = json.loads(mock_post.call_args_list[0][1]['data']) - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[0][1]["data"]) + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Preare our form data (body is actually the minimum requirement) # All of the rest of the variables can actually be over-ridden @@ -398,7 +373,7 @@ class NotifyTests(SimpleTestCase): # payload). The Payload contents of the POST request always take # priority to eliminate any ambiguity form_data = { - 'body': 'test notifiction', + "body": "test notifiction", } # Reset our mock object @@ -406,23 +381,26 @@ class NotifyTests(SimpleTestCase): # Send our notification by specifying the tag in the parameters response = self.client.post( - '/notify/{}?tag=home&format={}&type={}&title={}&body=ignored' - .format( - key, apprise.NotifyFormat.TEXT, - apprise.NotifyType.WARNING, "Test Title"), + "/notify/{}?tag=home&format={}&type={}&title={}&body=ignored".format( + key, + apprise.NotifyFormat.TEXT.value, + apprise.NotifyType.WARNING.value, + "Test Title", + ), form_data, - content_type='application/json') + content_type="application/json", + ) # Our notification was sent assert response.status_code == 200 assert mock_post.call_count == 1 - response = json.loads(mock_post.call_args_list[0][1]['data']) - assert response['title'] == "Test Title" - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.WARNING + response = json.loads(mock_post.call_args_list[0][1]["data"]) + assert response["title"] == "Test Title" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.WARNING.value - @mock.patch('requests.post') + @mock.patch("requests.post") def test_notify_with_tags_via_apprise(self, mock_post): """ Test notification handling when setting tags via the Apprise CLI @@ -431,7 +409,7 @@ class NotifyTests(SimpleTestCase): # Disable Throttling to speed testing apprise.plugins.NotifyBase.request_rate_per_sec = 0 # Ensure we're enabled for the purpose of our testing - N_MGR['json'].enabled = True + N_MGR["json"].enabled = True # Prepare our response response = requests.Request() @@ -439,7 +417,7 @@ class NotifyTests(SimpleTestCase): mock_post.return_value = response # our key to use - key = 'test_notify_with_tags_via_apprise' + key = "test_notify_with_tags_via_apprise" # Valid Yaml Configuration config = """ @@ -449,9 +427,7 @@ class NotifyTests(SimpleTestCase): """ # Load our configuration (it will be detected as YAML) - response = self.client.post( - '/add/{}'.format(key), - {'config': config}) + response = self.client.post("/add/{}".format(key), {"config": config}) assert response.status_code == 200 # Reset our mock object @@ -459,17 +435,19 @@ class NotifyTests(SimpleTestCase): # Preare our form data form_data = { - 'body': 'test notifiction', - 'type': apprise.NotifyType.INFO, - 'format': apprise.NotifyFormat.TEXT, + "body": "test notifiction", + "type": apprise.NotifyType.INFO.value, + "format": apprise.NotifyFormat.TEXT.value, # Support Array - 'tag': [('home', 'summer-home')], + "tag": [("home", "summer-home")], } # Send our notification response = self.client.post( - '/notify/{}/'.format(key), content_type='application/json', - data=form_data) + "/notify/{}/".format(key), + content_type="application/json", + data=form_data, + ) # Nothing could be notified as there were no tag matches for 'home' # AND 'summer-home' @@ -480,65 +458,71 @@ class NotifyTests(SimpleTestCase): mock_post.reset_mock() # Update our tags - form_data['tag'] = ['home', 'summer-home'] + form_data["tag"] = ["home", "summer-home"] # Now let's send our notification by specifying the tag in the # parameters # Send our notification response = self.client.post( - '/notify/{}/'.format(key), content_type='application/json', - data=form_data) + "/notify/{}/".format(key), + content_type="application/json", + data=form_data, + ) # Our notification was sent (as we matched 'home' OR' 'summer-home') assert response.status_code == 200 assert mock_post.call_count == 1 # Test our posted data - response = json.loads(mock_post.call_args_list[0][1]['data']) - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[0][1]["data"]) + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Reset our mock object mock_post.reset_mock() # use the `tags` keyword instead which is also supported - del form_data['tag'] - form_data['tags'] = ['home', 'summer-home'] + del form_data["tag"] + form_data["tags"] = ["home", "summer-home"] # Now let's send our notification by specifying the tag in the # parameters # Send our notification response = self.client.post( - '/notify/{}/'.format(key), content_type='application/json', - data=form_data) + "/notify/{}/".format(key), + content_type="application/json", + data=form_data, + ) # Our notification was sent (as we matched 'home' OR' 'summer-home') assert response.status_code == 200 assert mock_post.call_count == 1 # Test our posted data - response = json.loads(mock_post.call_args_list[0][1]['data']) - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[0][1]["data"]) + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Reset our mock object mock_post.reset_mock() # use the `tag` and `tags` keyword causes tag to always take priority - form_data['tag'] = ['invalid'] - form_data['tags'] = ['home', 'summer-home'] + form_data["tag"] = ["invalid"] + form_data["tags"] = ["home", "summer-home"] # Now let's send our notification by specifying the tag in the # parameters # Send our notification response = self.client.post( - '/notify/{}/'.format(key), content_type='application/json', - data=form_data) + "/notify/{}/".format(key), + content_type="application/json", + data=form_data, + ) # Our notification failed because 'tag' took priority over 'tags' and # it contains an invalid entry @@ -549,16 +533,18 @@ class NotifyTests(SimpleTestCase): mock_post.reset_mock() # integers or non string not accepted - form_data['tag'] = 42 - del form_data['tags'] + form_data["tag"] = 42 + del form_data["tags"] # Now let's send our notification by specifying the tag in the # parameters # Send our notification response = self.client.post( - '/notify/{}/'.format(key), content_type='application/json', - data=form_data) + "/notify/{}/".format(key), + content_type="application/json", + data=form_data, + ) # Our notification failed because no tags were loaded assert response.status_code == 400 @@ -568,15 +554,17 @@ class NotifyTests(SimpleTestCase): mock_post.reset_mock() # integers or non string not accepted - form_data['tag'] = [42, 'valid', 5.4] + form_data["tag"] = [42, "valid", 5.4] # Now let's send our notification by specifying the tag in the # parameters # Send our notification response = self.client.post( - '/notify/{}/'.format(key), content_type='application/json', - data=form_data) + "/notify/{}/".format(key), + content_type="application/json", + data=form_data, + ) # Our notification makes it through the list check and into the # Apprise library. It will be at that level that the tags will fail @@ -589,26 +577,28 @@ class NotifyTests(SimpleTestCase): # continued to verify the use of the `tag` and `tags` keyword # where tag priorities over tags - form_data['tags'] = ['invalid'] - form_data['tag'] = ['home', 'summer-home'] + form_data["tags"] = ["invalid"] + form_data["tag"] = ["home", "summer-home"] # Now let's send our notification by specifying the tag in the # parameters # Send our notification response = self.client.post( - '/notify/{}/'.format(key), content_type='application/json', - data=form_data) + "/notify/{}/".format(key), + content_type="application/json", + data=form_data, + ) # Our notification was sent (as we matched 'home' OR' 'summer-home') assert response.status_code == 200 assert mock_post.call_count == 1 # Test our posted data - response = json.loads(mock_post.call_args_list[0][1]['data']) - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[0][1]["data"]) + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Reset our mock object mock_post.reset_mock() @@ -619,44 +609,48 @@ class NotifyTests(SimpleTestCase): # payload). The Payload contents of the POST request always take # priority to eliminate any ambiguity form_data = { - 'body': 'test notifiction', + "body": "test notifiction", } # Send our notification by specifying the tag in the parameters response = self.client.post( - '/notify/{}?tag=home&format={}&type={}&title={}&body=ignored' - .format( - key, apprise.NotifyFormat.TEXT, - apprise.NotifyType.WARNING, "Test Title"), + "/notify/{}?tag=home&format={}&type={}&title={}&body=ignored".format( + key, + apprise.NotifyFormat.TEXT.value, + apprise.NotifyType.WARNING.value, + "Test Title", + ), form_data, - content_type='application/json') + content_type="application/json", + ) # Our notification was sent assert response.status_code == 200 assert mock_post.call_count == 1 - response = json.loads(mock_post.call_args_list[0][1]['data']) - assert response['title'] == "Test Title" - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.WARNING + response = json.loads(mock_post.call_args_list[0][1]["data"]) + assert response["title"] == "Test Title" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.WARNING.value # Test case where RequestDataTooBig thrown # Reset our mock object mock_post.reset_mock() - with mock.patch('json.loads') as mock_loads: + with mock.patch("json.loads") as mock_loads: mock_loads.side_effect = RequestDataTooBig() # Send our notification by specifying the tag in the parameters response = self.client.post( - f'/notify/{key}?tag=home&body=test', + f"/notify/{key}?tag=home&body=test", form_data, - content_type='application/json') + content_type="application/json", + ) # Our notification failed assert response.status_code == 431 assert mock_post.call_count == 0 - @mock.patch('requests.post') + @mock.patch("requests.post") def test_advanced_notify_with_tags(self, mock_post): """ Test advanced notification handling when setting tags @@ -665,7 +659,7 @@ class NotifyTests(SimpleTestCase): # Disable Throttling to speed testing apprise.plugins.NotifyBase.request_rate_per_sec = 0 # Ensure we're enabled for the purpose of our testing - N_MGR['json'].enabled = True + N_MGR["json"].enabled = True # Prepare our response response = requests.Request() @@ -673,10 +667,11 @@ class NotifyTests(SimpleTestCase): mock_post.return_value = response # our key to use - key = 'test_adv_notify_with_tags' + key = "test_adv_notify_with_tags" # Valid Yaml Configuration - config = cleandoc(""" + config = cleandoc( + """ version: 1 tag: panic @@ -687,24 +682,22 @@ class NotifyTests(SimpleTestCase): tag: devops, high - json://user:pass@localhost?+url=3: tag: cris, emergency - """) + """ + ) # Load our configuration (it will be detected as YAML) - response = self.client.post( - '/add/{}'.format(key), - {'config': config}) + response = self.client.post("/add/{}".format(key), {"config": config}) assert response.status_code == 200 # Preare our form data form_data = { - 'body': 'test notifiction', - 'type': apprise.NotifyType.INFO, - 'format': apprise.NotifyFormat.TEXT, + "body": "test notifiction", + "type": apprise.NotifyType.INFO.value, + "format": apprise.NotifyFormat.TEXT.value, } # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form_data) + response = self.client.post("/notify/{}".format(key), form_data) # Nothing could be notified as there were no tag matches assert response.status_code == 424 @@ -712,36 +705,33 @@ class NotifyTests(SimpleTestCase): # Let's identify a tag, but note that it won't match anything # parameters - response = self.client.post( - '/notify/{}?tag=nomatch'.format(key), form_data) + response = self.client.post("/notify/{}?tag=nomatch".format(key), form_data) # Nothing could be notified as there were no tag matches assert response.status_code == 424 assert mock_post.call_count == 0 # Now let's do devops AND notify - response = self.client.post( - '/notify/{}?tag=devops notify'.format(key), form_data) + response = self.client.post("/notify/{}?tag=devops notify".format(key), form_data) # Our notification was sent assert response.status_code == 200 assert mock_post.call_count == 1 # Test our posted data - response = json.loads(mock_post.call_args_list[0][1]['data']) - headers = mock_post.call_args_list[0][1]['headers'] - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[0][1]["data"]) + headers = mock_post.call_args_list[0][1]["headers"] + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Verify we matched the first entry only - assert headers['url'] == '1' + assert headers["url"] == "1" # Reset our object mock_post.reset_mock() # Now let's do panic - response = self.client.post( - '/notify/{}?tag=panic'.format(key), form_data) + response = self.client.post("/notify/{}?tag=panic".format(key), form_data) # Our notification was sent to each match assert response.status_code == 200 @@ -752,16 +742,15 @@ class NotifyTests(SimpleTestCase): # Let's store our tag in our form form_data = { - 'body': 'test notifiction', - 'type': apprise.NotifyType.INFO, - 'format': apprise.NotifyFormat.TEXT, + "body": "test notifiction", + "type": apprise.NotifyType.INFO.value, + "format": apprise.NotifyFormat.TEXT.value, # (devops AND cris) OR (notify AND high) - 'tag': 'devops cris, notify high' + "tag": "devops cris, notify high", } # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form_data) + response = self.client.post("/notify/{}".format(key), form_data) # Nothing could be notified as there were no tag matches in our # form body that matched the anded comnbination @@ -770,11 +759,10 @@ class NotifyTests(SimpleTestCase): # Trigger on high OR emergency (some empty garbage at the end to # tidy/ignore - form_data['tag'] = 'high, emergency, , ,' + form_data["tag"] = "high, emergency, , ," # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form_data) + response = self.client.post("/notify/{}".format(key), form_data) # Our notification was sent assert response.status_code == 200 @@ -782,31 +770,30 @@ class NotifyTests(SimpleTestCase): assert mock_post.call_count == 2 # Test our posted data - response = json.loads(mock_post.call_args_list[0][1]['data']) - headers = mock_post.call_args_list[0][1]['headers'] - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[0][1]["data"]) + headers = mock_post.call_args_list[0][1]["headers"] + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Verify we matched the first entry only - assert headers['url'] == '2' + assert headers["url"] == "2" - response = json.loads(mock_post.call_args_list[1][1]['data']) - headers = mock_post.call_args_list[1][1]['headers'] - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[1][1]["data"]) + headers = mock_post.call_args_list[1][1]["headers"] + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Verify we matched the first entry only - assert headers['url'] == '3' + assert headers["url"] == "3" # Reset our object mock_post.reset_mock() # Trigger on notify OR cris - form_data['tag'] = 'notify, cris' + form_data["tag"] = "notify, cris" # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form_data) + response = self.client.post("/notify/{}".format(key), form_data) # Our notification was sent assert response.status_code == 200 @@ -814,31 +801,30 @@ class NotifyTests(SimpleTestCase): assert mock_post.call_count == 2 # Test our posted data - response = json.loads(mock_post.call_args_list[0][1]['data']) - headers = mock_post.call_args_list[0][1]['headers'] - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[0][1]["data"]) + headers = mock_post.call_args_list[0][1]["headers"] + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Verify we matched the first entry only - assert headers['url'] == '1' + assert headers["url"] == "1" - response = json.loads(mock_post.call_args_list[1][1]['data']) - headers = mock_post.call_args_list[1][1]['headers'] - assert response['title'] == '' - assert response['message'] == form_data['body'] - assert response['type'] == apprise.NotifyType.INFO + response = json.loads(mock_post.call_args_list[1][1]["data"]) + headers = mock_post.call_args_list[1][1]["headers"] + assert response["title"] == "" + assert response["message"] == form_data["body"] + assert response["type"] == apprise.NotifyType.INFO.value # Verify we matched the first entry only - assert headers['url'] == '3' + assert headers["url"] == "3" # Reset our object mock_post.reset_mock() # Trigger on notify AND cris (should not match anything) - form_data['tag'] = 'notify cris' + form_data["tag"] = "notify cris" # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form_data) + response = self.client.post("/notify/{}".format(key), form_data) assert response.status_code == 424 assert mock_post.call_count == 0 @@ -847,18 +833,17 @@ class NotifyTests(SimpleTestCase): mock_post.reset_mock() # Invalid characters in our tag - form_data['tag'] = '$' + form_data["tag"] = "$" # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form_data) + response = self.client.post("/notify/{}".format(key), form_data) # Our notification was sent assert response.status_code == 400 # We'll trigger on 2 entries assert mock_post.call_count == 0 - @mock.patch('apprise.NotifyBase.notify') + @mock.patch("apprise.NotifyBase.notify") def test_partial_notify_by_loaded_urls(self, mock_notify): """ Test notification handling when one or more of the services @@ -866,22 +851,25 @@ class NotifyTests(SimpleTestCase): """ # our key to use - key = 'test_partial_notify_by_loaded_urls' + key = "test_partial_notify_by_loaded_urls" # Add some content response = self.client.post( - '/add/{}'.format(key), + "/add/{}".format(key), { - 'urls': ', '.join([ - 'mailto://user:pass@hotmail.com', - 'mailto://user:pass@gmail.com', - ]), - }) + "urls": ", ".join( + [ + "mailto://user:pass@hotmail.com", + "mailto://user:pass@gmail.com", + ] + ), + }, + ) assert response.status_code == 200 # Preare our form data form_data = { - 'body': 'test notifiction', + "body": "test notifiction", } # At a minimum, just a body is required @@ -889,21 +877,20 @@ class NotifyTests(SimpleTestCase): assert form.is_valid() # Required to prevent None from being passed into self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # we always set a type if one wasn't done so already - assert form.cleaned_data['type'] == apprise.NotifyType.INFO + assert form.cleaned_data["type"] == apprise.NotifyType.INFO.value # we always set a format if one wasn't done so already - assert form.cleaned_data['format'] == apprise.NotifyFormat.TEXT + assert form.cleaned_data["format"] == apprise.NotifyFormat.TEXT.value # Set our return value; first we return a true, then we fail # on the second call mock_notify.side_effect = (True, False) # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 424 assert mock_notify.call_count == 2 @@ -915,13 +902,12 @@ class NotifyTests(SimpleTestCase): # Preare our form data form_data = { - 'body': 'test notifiction', - 'attachment': 'https://localhost/invalid/path/to/image.png', + "body": "test notifiction", + "attachment": "https://localhost/invalid/path/to/image.png", } # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form_data) + response = self.client.post("/notify/{}".format(key), form_data) # We fail because we couldn't retrieve our attachment assert response.status_code == 400 assert mock_notify.call_count == 0 @@ -931,18 +917,17 @@ class NotifyTests(SimpleTestCase): # Preare our form data form_data = { - 'body': 'test notifiction', - 'attach': 'https://localhost/invalid/path/to/image.png', + "body": "test notifiction", + "attach": "https://localhost/invalid/path/to/image.png", } # Send our notification - response = self.client.post( - '/notify/{}'.format(key), form_data) + response = self.client.post("/notify/{}".format(key), form_data) # We fail because we couldn't retrieve our attachment assert response.status_code == 400 assert mock_notify.call_count == 0 - @mock.patch('apprise.Apprise.notify') + @mock.patch("apprise.Apprise.notify") def test_notify_by_loaded_urls_with_json(self, mock_notify): """ Test adding a simple notification and notifying it using JSON @@ -952,25 +937,23 @@ class NotifyTests(SimpleTestCase): mock_notify.return_value = True # our key to use - key = 'test_notify_by_loaded_urls_with_json' + key = "test_notify_by_loaded_urls_with_json" # Add some content - response = self.client.post( - '/add/{}'.format(key), - {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # Preare our JSON data json_data = { - 'body': 'test notification', - 'type': apprise.NotifyType.WARNING, + "body": "test notification", + "type": apprise.NotifyType.WARNING.value, } # Send our notification as a JSON object response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) # Still supported @@ -982,9 +965,9 @@ class NotifyTests(SimpleTestCase): # Test referencing a key that doesn't exist response = self.client.post( - '/notify/non-existant-key', + "/notify/non-existant-key", data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) # Nothing notified @@ -993,9 +976,9 @@ class NotifyTests(SimpleTestCase): # Test sending a garbage JSON object response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data="{", - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 @@ -1003,9 +986,9 @@ class NotifyTests(SimpleTestCase): # Test sending with an invalid content type response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data="{}", - content_type='application/xml', + content_type="application/xml", ) assert response.status_code == 400 @@ -1013,9 +996,9 @@ class NotifyTests(SimpleTestCase): # Test sending without any content at all response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data="{}", - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 @@ -1023,31 +1006,29 @@ class NotifyTests(SimpleTestCase): # Test sending without a body json_data = { - 'type': apprise.NotifyType.WARNING, + "type": apprise.NotifyType.WARNING.value, } response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 assert mock_notify.call_count == 0 # Test inability to prepare writing config to disk - json_data = { - 'body': 'test message' - } + json_data = {"body": "test message"} # Test the handling of underlining disk/write exceptions - with mock.patch('gzip.open') as mock_open: + with mock.patch("gzip.open") as mock_open: mock_open.side_effect = OSError() # We'll fail to write our key now response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) # internal errors are correctly identified @@ -1058,16 +1039,13 @@ class NotifyTests(SimpleTestCase): mock_notify.reset_mock() # Test with invalid format - json_data = { - 'body': 'test message', - 'format': 'invalid' - } + json_data = {"body": "test message", "format": "invalid"} # Test case with format set to invalid response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 @@ -1079,15 +1057,15 @@ class NotifyTests(SimpleTestCase): # If an empty format is specified, it is accepted and # no imput format is specified json_data = { - 'body': 'test message', - 'format': None, + "body": "test message", + "format": None, } # Test case with format changed response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 200 @@ -1099,16 +1077,16 @@ class NotifyTests(SimpleTestCase): # If an empty format is specified, it is accepted and # no imput format is specified json_data = { - 'body': 'test message', - 'format': None, - 'attach': 'https://localhost/invalid/path/to/image.png', + "body": "test message", + "format": None, + "attach": "https://localhost/invalid/path/to/image.png", } # Test case with format changed response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) # We failed to send notification because we couldn't fetch the @@ -1120,14 +1098,14 @@ class NotifyTests(SimpleTestCase): mock_notify.reset_mock() json_data = { - 'body': 'test message', + "body": "test message", } # Same results for any empty string: response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 200 @@ -1137,108 +1115,108 @@ class NotifyTests(SimpleTestCase): mock_notify.reset_mock() headers = { - 'HTTP_X_APPRISE_LOG_LEVEL': 'debug', + "HTTP_X_APPRISE_LOG_LEVEL": "debug", # Accept is over-ridden to be that of the content type - 'HTTP_ACCEPT': 'text/plain', + "HTTP_ACCEPT": "text/plain", } # Test referencing a key that doesn't exist response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", **headers, ) assert response.status_code == 200 assert mock_notify.call_count == 1 - assert response['content-type'] == 'text/plain' + assert response["content-type"] == "text/plain" mock_notify.reset_mock() headers = { - 'HTTP_X_APPRISE_LOG_LEVEL': 'debug', + "HTTP_X_APPRISE_LOG_LEVEL": "debug", # Accept is over-ridden to be that of the content type - 'HTTP_ACCEPT': 'text/html', + "HTTP_ACCEPT": "text/html", } # Test referencing a key that doesn't exist response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", **headers, ) assert response.status_code == 200 assert mock_notify.call_count == 1 - assert response['content-type'] == 'text/html' + assert response["content-type"] == "text/html" mock_notify.reset_mock() # Test referencing a key that doesn't exist response = self.client.post( - '/notify/{}'.format(key), - data={'body': 'test'}, + "/notify/{}".format(key), + data={"body": "test"}, **headers, ) assert response.status_code == 200 assert mock_notify.call_count == 1 - assert response['content-type'].startswith('text/html') + assert response["content-type"].startswith("text/html") mock_notify.reset_mock() headers = { - 'HTTP_X_APPRISE_LOG_LEVEL': 'debug', - 'HTTP_ACCEPT': '*/*', + "HTTP_X_APPRISE_LOG_LEVEL": "debug", + "HTTP_ACCEPT": "*/*", } # Test referencing a key that doesn't exist response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", **headers, ) assert response.status_code == 200 assert mock_notify.call_count == 1 - assert response['content-type'] == 'application/json' + assert response["content-type"] == "application/json" headers = { - 'HTTP_X_APPRISE_LOG_LEVEL': 'invalid', - 'HTTP_ACCEPT': 'text/*', + "HTTP_X_APPRISE_LOG_LEVEL": "invalid", + "HTTP_ACCEPT": "text/*", } mock_notify.reset_mock() # Test referencing a key that doesn't exist response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", **headers, ) assert response.status_code == 200 assert mock_notify.call_count == 1 - assert response['content-type'] == 'text/html' + assert response["content-type"] == "text/html" mock_notify.reset_mock() # Test referencing a key that doesn't exist response = self.client.post( - '/notify/{}'.format(key), + "/notify/{}".format(key), data=json_data, **headers, ) assert response.status_code == 200 assert mock_notify.call_count == 1 - assert response['content-type'].startswith('text/html') + assert response["content-type"].startswith("text/html") - @mock.patch('apprise.plugins.email.NotifyEmail.send') + @mock.patch("apprise.plugins.email.NotifyEmail.send") def test_notify_with_filters(self, mock_send): """ Test workings of APPRISE_DENY_SERVICES and APPRISE_ALLOW_SERVICES @@ -1248,148 +1226,142 @@ class NotifyTests(SimpleTestCase): mock_send.return_value = True # our key to use - key = 'test_notify_with_restrictions' + key = "test_notify_with_restrictions" # Add some content - response = self.client.post( - '/add/{}'.format(key), - {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # Preare our JSON data json_data = { - 'body': 'test notifiction', - 'type': apprise.NotifyType.WARNING, + "body": "test notifiction", + "type": apprise.NotifyType.WARNING.value, } # Verify by default email is enabled - assert N_MGR['mailto'].enabled is True + assert N_MGR["mailto"].enabled is True # Send our service with the `mailto://` denied - with override_settings(APPRISE_ALLOW_SERVICES=""): - with override_settings(APPRISE_DENY_SERVICES="mailto"): - # Send our notification as a JSON object - response = self.client.post( - '/notify/{}'.format(key), - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES=""), override_settings(APPRISE_DENY_SERVICES="mailto"): + # Send our notification as a JSON object + response = self.client.post( + "/notify/{}".format(key), + data=json.dumps(json_data), + content_type="application/json", + ) - # mailto:// is disabled - assert response.status_code == 424 - assert mock_send.call_count == 0 + # mailto:// is disabled + assert response.status_code == 424 + assert mock_send.call_count == 0 - # What actually took place behind close doors: - assert N_MGR['mailto'].enabled is False + # What actually took place behind close doors: + assert N_MGR["mailto"].enabled is False - # Reset our flag (for next test) - N_MGR['mailto'].enabled = True + # Reset our flag (for next test) + N_MGR["mailto"].enabled = True # Reset Mock mock_send.reset_mock() # Send our service with the `mailto://` denied - with override_settings(APPRISE_ALLOW_SERVICES=""): - with override_settings(APPRISE_DENY_SERVICES="invalid, syslog"): - # Send our notification as a JSON object - response = self.client.post( - '/notify/{}'.format(key), - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES=""), \ + override_settings(APPRISE_DENY_SERVICES="invalid, syslog"): + # Send our notification as a JSON object + response = self.client.post( + "/notify/{}".format(key), + data=json.dumps(json_data), + content_type="application/json", + ) - # mailto:// is enabled - assert response.status_code == 200 - assert mock_send.call_count == 1 + # mailto:// is enabled + assert response.status_code == 200 + assert mock_send.call_count == 1 - # Verify that mailto was never turned off - assert N_MGR['mailto'].enabled is True + # Verify that mailto was never turned off + assert N_MGR["mailto"].enabled is True # Reset Mock mock_send.reset_mock() # Send our service with the `mailto://` being the only accepted type - with override_settings(APPRISE_ALLOW_SERVICES="mailto"): - with override_settings(APPRISE_DENY_SERVICES=""): - # Send our notification as a JSON object - response = self.client.post( - '/notify/{}'.format(key), - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES="mailto"), override_settings(APPRISE_DENY_SERVICES=""): + # Send our notification as a JSON object + response = self.client.post( + "/notify/{}".format(key), + data=json.dumps(json_data), + content_type="application/json", + ) - # mailto:// is enabled - assert response.status_code == 200 - assert mock_send.call_count == 1 + # mailto:// is enabled + assert response.status_code == 200 + assert mock_send.call_count == 1 - # Verify email was never turned off - assert N_MGR['mailto'].enabled is True + # Verify email was never turned off + assert N_MGR["mailto"].enabled is True # Reset Mock mock_send.reset_mock() # Send our service with the `mailto://` being the only accepted type - with override_settings(APPRISE_ALLOW_SERVICES="invalid, mailtos"): - with override_settings(APPRISE_DENY_SERVICES=""): - # Send our notification as a JSON object - response = self.client.post( - '/notify/{}'.format(key), - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES="invalid, mailtos"), \ + override_settings(APPRISE_DENY_SERVICES=""): + # Send our notification as a JSON object + response = self.client.post( + "/notify/{}".format(key), + data=json.dumps(json_data), + content_type="application/json", + ) - # mailto:// is enabled - assert response.status_code == 200 - assert mock_send.call_count == 1 + # mailto:// is enabled + assert response.status_code == 200 + assert mock_send.call_count == 1 - # Verify email was never turned off - assert N_MGR['mailto'].enabled is True + # Verify email was never turned off + assert N_MGR["mailto"].enabled is True # Reset Mock mock_send.reset_mock() # Send our service with the `mailto://` being the only accepted type - with override_settings(APPRISE_ALLOW_SERVICES="syslog"): - with override_settings(APPRISE_DENY_SERVICES=""): - # Send our notification as a JSON object - response = self.client.post( - '/notify/{}'.format(key), - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES="syslog"), override_settings(APPRISE_DENY_SERVICES=""): + # Send our notification as a JSON object + response = self.client.post( + "/notify/{}".format(key), + data=json.dumps(json_data), + content_type="application/json", + ) - # mailto:// is disabled - assert response.status_code == 424 - assert mock_send.call_count == 0 + # mailto:// is disabled + assert response.status_code == 424 + assert mock_send.call_count == 0 - # What actually took place behind close doors: - assert N_MGR['mailto'].enabled is False + # What actually took place behind close doors: + assert N_MGR["mailto"].enabled is False - # Reset our flag (for next test) - N_MGR['mailto'].enabled = True + # Reset our flag (for next test) + N_MGR["mailto"].enabled = True # Reset Mock mock_send.reset_mock() # Test case where there is simply no over-rides defined - with override_settings(APPRISE_ALLOW_SERVICES=""): - with override_settings(APPRISE_DENY_SERVICES=""): - # Send our notification as a JSON object - response = self.client.post( - '/notify/{}'.format(key), - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES=""), override_settings(APPRISE_DENY_SERVICES=""): + # Send our notification as a JSON object + response = self.client.post( + "/notify/{}".format(key), + data=json.dumps(json_data), + content_type="application/json", + ) - # json:// is disabled - assert response.status_code == 200 - assert mock_send.call_count == 1 + # json:// is disabled + assert response.status_code == 200 + assert mock_send.call_count == 1 - # nothing was changed - assert N_MGR['mailto'].enabled is True + # nothing was changed + assert N_MGR["mailto"].enabled is True @override_settings(APPRISE_RECURSION_MAX=1) - @mock.patch('apprise.Apprise.notify') + @mock.patch("apprise.Apprise.notify") def test_stateful_notify_recursion(self, mock_notify): """ Test recursion an id header details as part of post @@ -1399,34 +1371,31 @@ class NotifyTests(SimpleTestCase): mock_notify.return_value = True # our key to use - key = 'test_stateful_notify_recursion' + key = "test_stateful_notify_recursion" # Add some content - response = self.client.post( - '/add/{}'.format(key), - {'urls': 'mailto://user:pass@yahoo.ca'}) + response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"}) assert response.status_code == 200 # Form data form_data = { - 'body': 'test notifiction', + "body": "test notifiction", } # Define our headers we plan to pass along with our request headers = { - 'HTTP_X-APPRISE-ID': 'abc123', - 'HTTP_X-APPRISE-RECURSION-COUNT': str(1), + "HTTP_X-APPRISE-ID": "abc123", + "HTTP_X-APPRISE-RECURSION-COUNT": str(1), } # Send our notification - response = self.client.post( - '/notify/{}'.format(key), data=form_data, **headers) + response = self.client.post("/notify/{}".format(key), data=form_data, **headers) assert response.status_code == 200 assert mock_notify.call_count == 1 headers = { # Header specified but with whitespace - 'HTTP_X-APPRISE-ID': ' ', + "HTTP_X-APPRISE-ID": " ", # No Recursion value specified } @@ -1434,52 +1403,48 @@ class NotifyTests(SimpleTestCase): mock_notify.reset_mock() # Recursion limit reached - response = self.client.post( - '/notify/{}'.format(key), data=form_data, **headers) + response = self.client.post("/notify/{}".format(key), data=form_data, **headers) assert response.status_code == 200 assert mock_notify.call_count == 1 headers = { - 'HTTP_X-APPRISE-ID': 'abc123', + "HTTP_X-APPRISE-ID": "abc123", # Recursion Limit hit - 'HTTP_X-APPRISE-RECURSION-COUNT': str(2), + "HTTP_X-APPRISE-RECURSION-COUNT": str(2), } # Reset our mock object mock_notify.reset_mock() # Recursion limit reached - response = self.client.post( - '/notify/{}'.format(key), data=form_data, **headers) + response = self.client.post("/notify/{}".format(key), data=form_data, **headers) assert response.status_code == 406 assert mock_notify.call_count == 0 headers = { - 'HTTP_X-APPRISE-ID': 'abc123', + "HTTP_X-APPRISE-ID": "abc123", # Negative recursion value (bad request) - 'HTTP_X-APPRISE-RECURSION-COUNT': str(-1), + "HTTP_X-APPRISE-RECURSION-COUNT": str(-1), } # Reset our mock object mock_notify.reset_mock() # invalid recursion specified - response = self.client.post( - '/notify/{}'.format(key), data=form_data, **headers) + response = self.client.post("/notify/{}".format(key), data=form_data, **headers) assert response.status_code == 400 assert mock_notify.call_count == 0 headers = { - 'HTTP_X-APPRISE-ID': 'abc123', + "HTTP_X-APPRISE-ID": "abc123", # Invalid recursion value (bad request) - 'HTTP_X-APPRISE-RECURSION-COUNT': 'invalid', + "HTTP_X-APPRISE-RECURSION-COUNT": "invalid", } # Reset our mock object mock_notify.reset_mock() # invalid recursion specified - response = self.client.post( - '/notify/{}'.format(key), data=form_data, **headers) + response = self.client.post("/notify/{}".format(key), data=form_data, **headers) assert response.status_code == 400 assert mock_notify.call_count == 0 diff --git a/apprise_api/api/tests/test_payload_mapper.py b/apprise_api/api/tests/test_payload_mapper.py index 9845e7f..e93c9e2 100644 --- a/apprise_api/api/tests/test_payload_mapper.py +++ b/apprise_api/api/tests/test_payload_mapper.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2024 Chris Caron # All rights reserved. @@ -23,6 +22,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. from django.test import SimpleTestCase + from ..payload_mapper import remap_fields @@ -41,9 +41,9 @@ class NotifyPayloadMapper(SimpleTestCase): # rules = {} payload = { - 'format': 'markdown', - 'title': 'title', - 'body': '# body', + "format": "markdown", + "title": "title", + "body": "# body", } payload_orig = payload.copy() @@ -58,32 +58,29 @@ class NotifyPayloadMapper(SimpleTestCase): # rules = { # map 'as' to 'format' - 'as': 'format', + "as": "format", # map 'subject' to 'title' - 'subject': 'title', + "subject": "title", # map 'content' to 'body' - 'content': 'body', + "content": "body", # 'missing' is an invalid entry so this will be skipped - 'unknown': 'missing', - + "unknown": "missing", # Empty field - 'attachment': '', - + "attachment": "", # Garbage is an field that can be removed since it doesn't # conflict with the form - 'garbage': '', - + "garbage": "", # Tag - 'tag': 'test', + "tag": "test", } payload = { - 'as': 'markdown', - 'subject': 'title', - 'content': '# body', - 'tag': '', - 'unknown': 'hmm', - 'attachment': '', - 'garbage': '', + "as": "markdown", + "subject": "title", + "content": "# body", + "tag": "", + "unknown": "hmm", + "attachment": "", + "garbage": "", } # Map our fields @@ -91,11 +88,11 @@ class NotifyPayloadMapper(SimpleTestCase): # Our field mappings have taken place assert payload == { - 'tag': 'test', - 'unknown': 'missing', - 'format': 'markdown', - 'title': 'title', - 'body': '# body', + "tag": "test", + "unknown": "missing", + "format": "markdown", + "title": "title", + "body": "# body", } # @@ -104,18 +101,18 @@ class NotifyPayloadMapper(SimpleTestCase): rules = { # # map 'content' to 'body' - 'content': 'body', + "content": "body", # a double mapping to body will trigger an error - 'message': 'body', + "message": "body", # Swapping fields - 'body': 'another set of data', + "body": "another set of data", } payload = { - 'as': 'markdown', - 'subject': 'title', - 'content': '# content body', - 'message': '# message body', - 'body': 'another set of data', + "as": "markdown", + "subject": "title", + "content": "# content body", + "message": "# message body", + "body": "another set of data", } # Map our fields @@ -123,9 +120,9 @@ class NotifyPayloadMapper(SimpleTestCase): # Our information gets swapped assert payload == { - 'as': 'markdown', - 'subject': 'title', - 'body': 'another set of data', + "as": "markdown", + "subject": "title", + "body": "another set of data", } # @@ -134,12 +131,12 @@ class NotifyPayloadMapper(SimpleTestCase): rules = { # # map 'content' to 'body' - 'title': 'body', + "title": "body", } payload = { - 'format': 'markdown', - 'title': 'body', - 'body': '# title', + "format": "markdown", + "title": "body", + "body": "# title", } # Map our fields @@ -147,9 +144,9 @@ class NotifyPayloadMapper(SimpleTestCase): # Our information gets swapped assert payload == { - 'format': 'markdown', - 'title': '# title', - 'body': 'body', + "format": "markdown", + "title": "# title", + "body": "body", } # @@ -158,11 +155,11 @@ class NotifyPayloadMapper(SimpleTestCase): rules = { # # map 'content' to 'body' - 'title': 'body', + "title": "body", } payload = { - 'format': 'markdown', - 'title': 'body', + "format": "markdown", + "title": "body", } # Map our fields @@ -170,8 +167,8 @@ class NotifyPayloadMapper(SimpleTestCase): # Our information gets swapped assert payload == { - 'format': 'markdown', - 'body': 'body', + "format": "markdown", + "body": "body", } # @@ -180,12 +177,12 @@ class NotifyPayloadMapper(SimpleTestCase): rules = { # # map 'content' to 'body' - 'content': 'body', + "content": "body", } payload = { - 'format': 'markdown', - 'content': 'the message', - 'body': 'to-be-replaced', + "format": "markdown", + "content": "the message", + "body": "to-be-replaced", } # Map our fields @@ -193,26 +190,26 @@ class NotifyPayloadMapper(SimpleTestCase): # Our information gets swapped assert payload == { - 'format': 'markdown', - 'body': 'the message', + "format": "markdown", + "body": "the message", } # # mapping of fields don't align - test 6 # rules = { - 'payload': 'body', - 'fmt': 'format', - 'extra': 'tag', + "payload": "body", + "fmt": "format", + "extra": "tag", } payload = { - 'format': 'markdown', - 'type': 'info', - 'title': '', - 'body': '## test notifiction', - 'attachment': None, - 'tag': 'general', - 'tags': '', + "format": "markdown", + "type": "info", + "title": "", + "body": "## test notifiction", + "attachment": None, + "tag": "general", + "tags": "", } # Make a copy of our original payload diff --git a/apprise_api/api/tests/test_stateful_notify.py b/apprise_api/api/tests/test_stateful_notify.py index 40f125f..f077bf4 100644 --- a/apprise_api/api/tests/test_stateful_notify.py +++ b/apprise_api/api/tests/test_stateful_notify.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2020 Chris Caron # All rights reserved. @@ -22,17 +21,20 @@ # 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.test import SimpleTestCase -from django.test.utils import override_settings -from unittest.mock import patch, Mock -from ..forms import NotifyForm -from ..utils import ConfigCache +import inspect from json import dumps import os 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 inspect + +import apprise + +from ..forms import NotifyForm +from ..utils import ConfigCache # Grant access to our Notification Manager Singleton N_MGR = apprise.manager_plugins.NotificationManager() @@ -49,15 +51,15 @@ class StatefulNotifyTests(SimpleTestCase): Test the retrieval of configuration when the lock is set """ # 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 # flag is set. All that overhead is skipped and we're denied access # right off the bat - response = self.client.post('/get/{}'.format(key)) + response = self.client.post("/get/{}".format(key)) assert response.status_code == 403 - @patch('requests.post') + @patch("requests.post") def test_stateful_configuration_io(self, mock_post): """ Test the writing, removal, writing and removal of configuration to @@ -65,50 +67,48 @@ class StatefulNotifyTests(SimpleTestCase): """ # our key to use - key = 'test_stateful_01' + key = "test_stateful_01" request = Mock() - request.content = b'ok' + request.content = b"ok" request.status_code = requests.codes.ok mock_post.return_value = request # Monkey Patch - N_MGR['mailto'].enabled = True + N_MGR["mailto"].enabled = True # Preare our list of URLs we want to save urls = [ - 'pushbullet=pbul://tokendetails', - 'general,json=json://hostname', + "pushbullet=pbul://tokendetails", + "general,json=json://hostname", ] # Monkey Patch - N_MGR['pbul'].enabled = True - N_MGR['json'].enabled = True + N_MGR["pbul"].enabled = True + N_MGR["json"].enabled = True # For 10 iterations, repeat these tests to verify that don't change # and our saved content is not different on subsequent calls. for _ in range(10): # 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 # Add our content - response = self.client.post( - '/add/{}'.format(key), - {'config': '\r\n'.join(urls)}) + response = self.client.post("/add/{}".format(key), {"config": "\r\n".join(urls)}) assert response.status_code == 200 # 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 - 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 form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, - 'tag': 'general', + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, + "tag": "general", } form = NotifyForm(data=form_data) @@ -116,53 +116,54 @@ class StatefulNotifyTests(SimpleTestCase): # Required to prevent None from being passed into # self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # We sent the notification successfully - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 assert mock_post.call_count == 1 mock_post.reset_mock() form_data = { - 'payload': '## test notification', - 'fmt': apprise.NotifyFormat.MARKDOWN, - 'extra': 'general', + "payload": "## test notification", + "fmt": apprise.NotifyFormat.MARKDOWN.value, + "extra": "general", } # We sent the notification successfully (use our rule mapping) # FORM response = self.client.post( - f'/notify/{key}/?:payload=body&:fmt=format&:extra=tag', - form_data) + f"/notify/{key}/?:payload=body&:fmt=format&:extra=tag", + form_data, + ) assert response.status_code == 200 assert mock_post.call_count == 1 mock_post.reset_mock() form_data = { - 'payload': '## test notification', - 'fmt': apprise.NotifyFormat.MARKDOWN, - 'extra': 'general', + "payload": "## test notification", + "fmt": apprise.NotifyFormat.MARKDOWN.value, + "extra": "general", } # We sent the notification successfully (use our rule mapping) # JSON 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), - content_type="application/json") + content_type="application/json", + ) assert response.status_code == 200 assert mock_post.call_count == 1 mock_post.reset_mock() form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, - 'tag': 'no-on-with-this-tag', + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, + "tag": "no-on-with-this-tag", } form = NotifyForm(data=form_data) @@ -170,22 +171,21 @@ class StatefulNotifyTests(SimpleTestCase): # Required to prevent None from being passed into # self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # No one to notify - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 424 assert mock_post.call_count == 0 mock_post.reset_mock() # Now empty our data - response = self.client.post('/del/{}'.format(key)) + response = self.client.post("/del/{}".format(key)) assert response.status_code == 200 # 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 # Reset our count @@ -193,28 +193,26 @@ class StatefulNotifyTests(SimpleTestCase): # Now we do a similar approach as the above except we remove the # configuration from under the application - key = 'test_stateful_02' + key = "test_stateful_02" for _ in range(10): # 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 # Add our content - response = self.client.post( - '/add/{}'.format(key), - {'config': '\r\n'.join(urls)}) + response = self.client.post("/add/{}".format(key), {"config": "\r\n".join(urls)}) assert response.status_code == 200 # 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 - 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 form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, } form = NotifyForm(data=form_data) @@ -222,11 +220,10 @@ class StatefulNotifyTests(SimpleTestCase): # Required to prevent None from being passed into # self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # No one to notify (no tag specified) - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 424 assert mock_post.call_count == 0 @@ -237,9 +234,9 @@ class StatefulNotifyTests(SimpleTestCase): # Test tagging now # form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, - 'tag': 'general+json', + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, + "tag": "general+json", } form = NotifyForm(data=form_data) @@ -247,10 +244,9 @@ class StatefulNotifyTests(SimpleTestCase): # 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/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) # + (plus) not supported at this time assert response.status_code == 400 assert mock_post.call_count == 0 @@ -259,10 +255,10 @@ class StatefulNotifyTests(SimpleTestCase): mock_post.reset_mock() form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, # Plus with space inbetween - 'tag': 'general + json', + "tag": "general + json", } form = NotifyForm(data=form_data) @@ -270,10 +266,9 @@ class StatefulNotifyTests(SimpleTestCase): # 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/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) # + (plus) not supported at this time assert response.status_code == 400 assert mock_post.call_count == 0 @@ -281,10 +276,10 @@ class StatefulNotifyTests(SimpleTestCase): mock_post.reset_mock() form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, # Space (AND) - 'tag': 'general json', + "tag": "general json", } form = NotifyForm(data=form_data) @@ -292,20 +287,19 @@ class StatefulNotifyTests(SimpleTestCase): # 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/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 assert mock_post.call_count == 1 mock_post.reset_mock() form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, # Comma (OR) - 'tag': 'pushbullet, json', + "tag": "pushbullet, json", } form = NotifyForm(data=form_data) @@ -313,10 +307,9 @@ class StatefulNotifyTests(SimpleTestCase): # 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/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 # 2 endpoints hit @@ -325,7 +318,7 @@ class StatefulNotifyTests(SimpleTestCase): # Now remove the file directly (as though one # removed the configuration directory) 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) # The removal os.unlink(entry) @@ -333,30 +326,31 @@ class StatefulNotifyTests(SimpleTestCase): assert not os.path.isfile(entry) # 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 # Reset our count mock_post.reset_mock() - @patch('requests.post') + @patch("requests.post") def test_stateful_group_dict_notify(self, mock_post): """ Test the handling of a group defined as a dictionary """ # our key to use - key = 'test_stateful_group_notify_dict' + key = "test_stateful_group_notify_dict" request = Mock() - request.content = b'ok' + request.content = b"ok" request.status_code = requests.codes.ok mock_post.return_value = request # Monkey Patch - N_MGR['mailto'].enabled = True + N_MGR["mailto"].enabled = True - config = inspect.cleandoc(""" + config = inspect.cleandoc( + """ version: 1 groups: mygroup: user1, user2 @@ -367,37 +361,35 @@ class StatefulNotifyTests(SimpleTestCase): tag: user1 - to: user2@example.com tag: user2 - """) + """ + ) # Monkey Patch - N_MGR['json'].enabled = True + N_MGR["json"].enabled = True # Add our content - response = self.client.post( - '/add/{}'.format(key), - {'config': config}) + response = self.client.post("/add/{}".format(key), {"config": config}) assert response.status_code == 200 # 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 - for tag in ('user1', 'user2'): + for tag in ("user1", "user2"): form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, - 'tag': tag, + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, + "tag": tag, } form = NotifyForm(data=form_data) assert form.is_valid() # Required to prevent None from being passed into # self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # We sent the notification successfully - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 # Our single endpoint is notified @@ -407,9 +399,9 @@ class StatefulNotifyTests(SimpleTestCase): # Now let's notify by our group form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, - 'tag': 'mygroup', + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, + "tag": "mygroup", } form = NotifyForm(data=form_data) @@ -417,11 +409,10 @@ class StatefulNotifyTests(SimpleTestCase): # Required to prevent None from being passed into # self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # We sent the notification successfully - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 # Our 2 endpoints are notified @@ -430,30 +421,31 @@ class StatefulNotifyTests(SimpleTestCase): mock_post.reset_mock() # Now empty our data - response = self.client.post('/del/{}'.format(key)) + response = self.client.post("/del/{}".format(key)) assert response.status_code == 200 # Reset our count mock_post.reset_mock() - @patch('requests.post') + @patch("requests.post") def test_stateful_group_dictlist_notify(self, mock_post): """ Test the handling of a group defined as a list of dictionaries """ # our key to use - key = 'test_stateful_group_notify_list_dict' + key = "test_stateful_group_notify_list_dict" request = Mock() - request.content = b'ok' + request.content = b"ok" request.status_code = requests.codes.ok mock_post.return_value = request # Monkey Patch - N_MGR['mailto'].enabled = True + N_MGR["mailto"].enabled = True - config = inspect.cleandoc(""" + config = inspect.cleandoc( + """ version: 1 groups: - mygroup: user1, user2 @@ -464,37 +456,35 @@ class StatefulNotifyTests(SimpleTestCase): tag: user1 - to: user2@example.com tag: user2 - """) + """ + ) # Monkey Patch - N_MGR['json'].enabled = True + N_MGR["json"].enabled = True # Add our content - response = self.client.post( - '/add/{}'.format(key), - {'config': config}) + response = self.client.post("/add/{}".format(key), {"config": config}) assert response.status_code == 200 # 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 - for tag in ('user1', 'user2'): + for tag in ("user1", "user2"): form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, - 'tag': tag, + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, + "tag": tag, } form = NotifyForm(data=form_data) assert form.is_valid() # Required to prevent None from being passed into # self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # We sent the notification successfully - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 # Our single endpoint is notified @@ -504,9 +494,9 @@ class StatefulNotifyTests(SimpleTestCase): # Now let's notify by our group form_data = { - 'body': '## test notification', - 'format': apprise.NotifyFormat.MARKDOWN, - 'tag': 'mygroup', + "body": "## test notification", + "format": apprise.NotifyFormat.MARKDOWN.value, + "tag": "mygroup", } form = NotifyForm(data=form_data) @@ -514,11 +504,10 @@ class StatefulNotifyTests(SimpleTestCase): # Required to prevent None from being passed into # self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # We sent the notification successfully - response = self.client.post( - '/notify/{}'.format(key), form.cleaned_data) + response = self.client.post("/notify/{}".format(key), form.cleaned_data) assert response.status_code == 200 # Our 2 endpoints are notified @@ -527,7 +516,7 @@ class StatefulNotifyTests(SimpleTestCase): mock_post.reset_mock() # Now empty our data - response = self.client.post('/del/{}'.format(key)) + response = self.client.post("/del/{}".format(key)) assert response.status_code == 200 # Reset our count diff --git a/apprise_api/api/tests/test_stateless_notify.py b/apprise_api/api/tests/test_stateless_notify.py index 540b3d8..40fe0e0 100644 --- a/apprise_api/api/tests/test_stateless_notify.py +++ b/apprise_api/api/tests/test_stateless_notify.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,16 +21,19 @@ # 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.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 +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 +from ..forms import NotifyByUrlForm + # Grant access to our Notification Manager Singleton N_MGR = apprise.manager_plugins.NotificationManager() @@ -41,7 +43,7 @@ class StatelessNotifyTests(SimpleTestCase): Test stateless notifications """ - @mock.patch('apprise.Apprise.notify') + @mock.patch("apprise.Apprise.notify") def test_notify(self, mock_notify): """ Test sending a simple notification @@ -52,8 +54,8 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our form data form_data = { - 'urls': 'mailto://user:pass@hotmail.com', - 'body': 'test notifiction', + "urls": "mailto://user:pass@hotmail.com", + "body": "test notifiction", } # At a minimum 'body' is requred @@ -61,26 +63,26 @@ class StatelessNotifyTests(SimpleTestCase): assert form.is_valid() # 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 mock_notify.call_count == 1 # Reset our count mock_notify.reset_mock() form_data = { - 'urls': 'mailto://user:pass@hotmail.com', - 'body': 'test notifiction', - 'format': apprise.NotifyFormat.MARKDOWN, + "urls": "mailto://user:pass@hotmail.com", + "body": "test notifiction", + "format": apprise.NotifyFormat.MARKDOWN.value, } form = NotifyByUrlForm(data=form_data) assert form.is_valid() # 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 mock_notify.call_count == 1 @@ -88,19 +90,22 @@ class StatelessNotifyTests(SimpleTestCase): mock_notify.reset_mock() # Test Headers - for level in ('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', - 'TRACE', 'INVALID'): - + for level in ( + "CRITICAL", + "ERROR", + "WARNING", + "INFO", + "DEBUG", + "TRACE", + "INVALID", + ): form_data = { - 'urls': 'mailto://user:pass@hotmail.com', - 'body': 'test notifiction', - 'format': apprise.NotifyFormat.MARKDOWN, + "urls": "mailto://user:pass@hotmail.com", + "body": "test notifiction", + "format": apprise.NotifyFormat.MARKDOWN.value, } - attach_data = { - 'attachment': SimpleUploadedFile( - "attach.txt", b"content here", content_type="text/plain") - } + attach_data = {"attachment": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")} # At a minimum, just a body is required form = NotifyByUrlForm(form_data, attach_data) @@ -108,12 +113,11 @@ class StatelessNotifyTests(SimpleTestCase): # Prepare our header headers = { - 'HTTP_X-APPRISE-LOG-LEVEL': level, + "HTTP_X-APPRISE-LOG-LEVEL": level, } # Send our notification - response = self.client.post( - '/notify', form.cleaned_data, **headers) + response = self.client.post("/notify", form.cleaned_data, **headers) assert response.status_code == 200 assert mock_notify.call_count == 1 @@ -121,33 +125,32 @@ class StatelessNotifyTests(SimpleTestCase): mock_notify.reset_mock() form_data = { - 'payload': '## test notification', - 'fmt': apprise.NotifyFormat.MARKDOWN, - 'extra': 'mailto://user:pass@hotmail.com', + "payload": "## test notification", + "fmt": apprise.NotifyFormat.MARKDOWN.value, + "extra": "mailto://user:pass@hotmail.com", } # We sent the notification successfully (use our rule mapping) # FORM - response = self.client.post( - '/notify/?:payload=body&:fmt=format&:extra=urls', - form_data) + response = self.client.post("/notify/?:payload=body&:fmt=format&:extra=urls", form_data) assert response.status_code == 200 assert mock_notify.call_count == 1 mock_notify.reset_mock() form_data = { - 'payload': '## test notification', - 'fmt': apprise.NotifyFormat.MARKDOWN, - 'extra': 'mailto://user:pass@hotmail.com', + "payload": "## test notification", + "fmt": apprise.NotifyFormat.MARKDOWN.value, + "extra": "mailto://user:pass@hotmail.com", } # We sent the notification successfully (use our rule mapping) # JSON response = self.client.post( - '/notify/?:payload=body&:fmt=format&:extra=urls', + "/notify/?:payload=body&:fmt=format&:extra=urls", json.dumps(form_data), - content_type="application/json") + content_type="application/json", + ) assert response.status_code == 200 assert mock_notify.call_count == 1 @@ -155,9 +158,11 @@ class StatelessNotifyTests(SimpleTestCase): # Long Filename attach_data = { - 'attachment': SimpleUploadedFile( - "{}.txt".format('a' * 2000), - b"content here", content_type="text/plain") + "attachment": SimpleUploadedFile( + "{}.txt".format("a" * 2000), + b"content here", + content_type="text/plain", + ) } # At a minimum, just a body is required @@ -165,7 +170,7 @@ class StatelessNotifyTests(SimpleTestCase): assert form.is_valid() # 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 assert response.status_code == 400 @@ -175,20 +180,18 @@ class StatelessNotifyTests(SimpleTestCase): mock_notify.reset_mock() # Test Webhooks - with mock.patch('requests.post') as mock_post: + with mock.patch("requests.post") as mock_post: # Response object response = mock.Mock() response.status_code = requests.codes.ok mock_post.return_value = response - with override_settings( - APPRISE_WEBHOOK_URL='http://localhost/webhook/'): - + with override_settings(APPRISE_WEBHOOK_URL="http://localhost/webhook/"): # Preare our form data form_data = { - 'urls': 'mailto://user:pass@hotmail.com', - 'body': 'test notifiction', - 'format': apprise.NotifyFormat.MARKDOWN, + "urls": "mailto://user:pass@hotmail.com", + "body": "test notifiction", + "format": apprise.NotifyFormat.MARKDOWN.value, } # At a minimum, just a body is required @@ -197,10 +200,10 @@ class StatelessNotifyTests(SimpleTestCase): # Required to prevent None from being passed into # self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # Send our notification - response = self.client.post('/notify', form.cleaned_data) + response = self.client.post("/notify", form.cleaned_data) # Test our results assert response.status_code == 200 @@ -214,32 +217,32 @@ class StatelessNotifyTests(SimpleTestCase): mock_notify.reset_mock() form_data = { - 'urls': 'mailto://user:pass@hotmail.com', - 'body': 'test notifiction', + "urls": "mailto://user:pass@hotmail.com", + "body": "test notifiction", # Invalid formats cause an error - 'format': 'invalid' + "format": "invalid", } form = NotifyByUrlForm(data=form_data) assert not form.is_valid() # Required to prevent None from being passed into self.client.post() - del form.cleaned_data['attachment'] + del form.cleaned_data["attachment"] # Send our notification - response = self.client.post('/notify', form.cleaned_data) + response = self.client.post("/notify", form.cleaned_data) # Test our results assert response.status_code == 200 assert mock_notify.call_count == 1 - @mock.patch('apprise.NotifyBase.notify') + @mock.patch("apprise.NotifyBase.notify") def test_partial_notify(self, mock_notify): """ Test sending multiple notifications where one fails """ # 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 # on the second call @@ -247,11 +250,13 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our form data form_data = { - 'urls': ', '.join([ - 'mailto://user:pass@hotmail.com', - 'mailto://user:pass@gmail.com', - ]), - 'body': 'test notifiction', + "urls": ", ".join( + [ + "mailto://user:pass@hotmail.com", + "mailto://user:pass@gmail.com", + ] + ), + "body": "test notifiction", } # At a minimum 'body' is requred @@ -259,9 +264,9 @@ class StatelessNotifyTests(SimpleTestCase): assert form.is_valid() # 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 mock_notify.call_count == 2 @@ -270,16 +275,18 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our form data form_data = { - 'body': 'test notifiction', - 'urls': ', '.join([ - 'mailto://user:pass@hotmail.com', - 'mailto://user:pass@gmail.com', - ]), - 'attachment': 'https://localhost/invalid/path/to/image.png', + "body": "test notifiction", + "urls": ", ".join( + [ + "mailto://user:pass@hotmail.com", + "mailto://user:pass@gmail.com", + ] + ), + "attachment": "https://localhost/invalid/path/to/image.png", } # 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 assert response.status_code == 400 assert mock_notify.call_count == 0 @@ -289,16 +296,18 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our form data (support attach keyword) form_data = { - 'body': 'test notifiction', - 'urls': ', '.join([ - 'mailto://user:pass@hotmail.com', - 'mailto://user:pass@gmail.com', - ]), - 'attach': 'https://localhost/invalid/path/to/image.png', + "body": "test notifiction", + "urls": ", ".join( + [ + "mailto://user:pass@hotmail.com", + "mailto://user:pass@gmail.com", + ] + ), + "attach": "https://localhost/invalid/path/to/image.png", } # 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 assert response.status_code == 400 assert mock_notify.call_count == 0 @@ -308,19 +317,21 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our json data (and support attach keyword as alias) json_data = { - 'body': 'test notifiction', - 'urls': ', '.join([ - 'mailto://user:pass@hotmail.com', - 'mailto://user:pass@gmail.com', - ]), - 'attach': 'https://localhost/invalid/path/to/image.png', + "body": "test notifiction", + "urls": ", ".join( + [ + "mailto://user:pass@hotmail.com", + "mailto://user:pass@gmail.com", + ] + ), + "attach": "https://localhost/invalid/path/to/image.png", } # Same results response = self.client.post( - '/notify/', + "/notify/", data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) # We fail because we couldn't retrieve our attachment @@ -328,7 +339,7 @@ class StatelessNotifyTests(SimpleTestCase): assert mock_notify.call_count == 0 @override_settings(APPRISE_RECURSION_MAX=1) - @mock.patch('apprise.Apprise.notify') + @mock.patch("apprise.Apprise.notify") def test_stateless_notify_recursion(self, mock_notify): """ Test recursion an id header details as part of post @@ -338,15 +349,15 @@ class StatelessNotifyTests(SimpleTestCase): mock_notify.return_value = True headers = { - 'HTTP_X-APPRISE-ID': 'abc123', - 'HTTP_X-APPRISE-RECURSION-COUNT': str(1), + "HTTP_X-APPRISE-ID": "abc123", + "HTTP_X-APPRISE-RECURSION-COUNT": str(1), } # Preare our form data (without url specified) # content will fall back to default configuration form_data = { - 'urls': 'mailto://user:pass@hotmail.com', - 'body': 'test notifiction', + "urls": "mailto://user:pass@hotmail.com", + "body": "test notifiction", } # Monkey Patch @@ -357,16 +368,16 @@ class StatelessNotifyTests(SimpleTestCase): assert form.is_valid() # 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 - response = self.client.post('/notify', form.cleaned_data, **headers) + response = self.client.post("/notify", form.cleaned_data, **headers) assert response.status_code == 200 assert mock_notify.call_count == 1 headers = { # Header specified but with whitespace - 'HTTP_X-APPRISE-ID': ' ', + "HTTP_X-APPRISE-ID": " ", # No Recursion value specified } @@ -374,54 +385,54 @@ class StatelessNotifyTests(SimpleTestCase): mock_notify.reset_mock() # 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 mock_notify.call_count == 1 headers = { - 'HTTP_X-APPRISE-ID': 'abc123', + "HTTP_X-APPRISE-ID": "abc123", # Recursion Limit hit - 'HTTP_X-APPRISE-RECURSION-COUNT': str(2), + "HTTP_X-APPRISE-RECURSION-COUNT": str(2), } # Reset our count mock_notify.reset_mock() # 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 mock_notify.call_count == 0 headers = { - 'HTTP_X-APPRISE-ID': 'abc123', + "HTTP_X-APPRISE-ID": "abc123", # Negative recursion value (bad request) - 'HTTP_X-APPRISE-RECURSION-COUNT': str(-1), + "HTTP_X-APPRISE-RECURSION-COUNT": str(-1), } # Reset our count mock_notify.reset_mock() # 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 mock_notify.call_count == 0 headers = { - 'HTTP_X-APPRISE-ID': 'abc123', + "HTTP_X-APPRISE-ID": "abc123", # Invalid recursion value (bad request) - 'HTTP_X-APPRISE-RECURSION-COUNT': 'invalid', + "HTTP_X-APPRISE-RECURSION-COUNT": "invalid", } # Reset our count mock_notify.reset_mock() # 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 mock_notify.call_count == 0 @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): """ Test fallback to default URLS if none were otherwise specified @@ -434,7 +445,7 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our form data (without url specified) # content will fall back to default configuration form_data = { - 'body': 'test notifiction', + "body": "test notifiction", } # At a minimum 'body' is requred @@ -442,14 +453,14 @@ class StatelessNotifyTests(SimpleTestCase): assert form.is_valid() # 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 - response = self.client.post('/notify', form.cleaned_data) + response = self.client.post("/notify", form.cleaned_data) assert response.status_code == 200 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): """ Test sending a simple notification using JSON with GET @@ -461,15 +472,15 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our JSON data json_data = { - 'urls': 'json://user@my.domain.ca', - 'body': 'test notifiction', + "urls": "json://user@my.domain.ca", + "body": "test notifiction", } # Send our notification as a JSON object 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), - content_type='application/json', + content_type="application/json", ) # Still supported @@ -479,48 +490,48 @@ class StatelessNotifyTests(SimpleTestCase): # Reset our count 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() # Send our notification 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), - content_type='application/json', + content_type="application/json", ) # Our notification failed assert response.status_code == 431 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): """ Test HTML log formatting block is triggered in StatelessNotifyView """ mock_notify.return_value = True form_data = { - 'urls': 'json://user@localhost', - 'body': 'Testing HTML block', - 'type': apprise.NotifyType.INFO, + "urls": "json://user@localhost", + "body": "Testing HTML block", + "type": apprise.NotifyType.INFO.value, } headers = { - 'HTTP_Accept': 'text/html', + "HTTP_Accept": "text/html", } response = self.client.post( - '/notify', + "/notify", data=form_data, **headers, ) assert response.status_code == 200 assert mock_notify.call_count == 1 - assert response['Content-Type'].startswith('text/html') + assert response["Content-Type"].startswith("text/html") assert b'
    ' 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): """ Test sending a simple notification using JSON @@ -531,16 +542,16 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our JSON data without any urls json_data = { - 'urls': '', - 'body': 'test notifiction', - 'type': apprise.NotifyType.WARNING, + "urls": "", + "body": "test notifiction", + "type": apprise.NotifyType.WARNING.value, } # Send our empty notification as a JSON object response = self.client.post( - '/notify', + "/notify", data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) # Nothing notified @@ -549,16 +560,16 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our JSON data json_data = { - 'urls': 'mailto://user:pass@yahoo.ca', - 'body': 'test notifiction', - 'type': apprise.NotifyType.WARNING, + "urls": "mailto://user:pass@yahoo.ca", + "body": "test notifiction", + "type": apprise.NotifyType.WARNING.value, } # Send our notification as a JSON object response = self.client.post( - '/notify', + "/notify", data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) # Still supported @@ -570,9 +581,9 @@ class StatelessNotifyTests(SimpleTestCase): # Test sending a garbage JSON object response = self.client.post( - '/notify/', + "/notify/", data="{", - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 @@ -580,9 +591,9 @@ class StatelessNotifyTests(SimpleTestCase): # Test sending with an invalid content type response = self.client.post( - '/notify', + "/notify", data="{}", - content_type='application/xml', + content_type="application/xml", ) assert response.status_code == 400 @@ -590,9 +601,9 @@ class StatelessNotifyTests(SimpleTestCase): # Test sending without any content at all response = self.client.post( - '/notify/', + "/notify/", data="{}", - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 @@ -600,13 +611,13 @@ class StatelessNotifyTests(SimpleTestCase): # Test sending without a body json_data = { - 'type': apprise.NotifyType.WARNING, + "type": apprise.NotifyType.WARNING.value, } response = self.client.post( - '/notify', + "/notify", data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 @@ -617,24 +628,24 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our JSON data json_data = { - 'urls': 'mailto://user:pass@yahoo.ca', - 'body': 'test notifiction', + "urls": "mailto://user:pass@yahoo.ca", + "body": "test notifiction", # invalid server side format - 'format': 'invalid' + "format": "invalid", } # Send our notification as a JSON object response = self.client.post( - '/notify', + "/notify", data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) # Still supported assert response.status_code == 400 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): """ Test workings of APPRISE_DENY_SERVICES and APPRISE_ALLOW_SERVICES @@ -645,145 +656,143 @@ class StatelessNotifyTests(SimpleTestCase): # Preare our JSON data json_data = { - 'urls': 'json://user:pass@yahoo.ca', - 'body': 'test notifiction', - 'type': apprise.NotifyType.WARNING, + "urls": "json://user:pass@yahoo.ca", + "body": "test notifiction", + "type": apprise.NotifyType.WARNING.value, } # Send our notification as a JSON object response = self.client.post( - '/notify', + "/notify", data=json.dumps(json_data), - content_type='application/json', + content_type="application/json", ) # Ensure we're enabled for the purpose of our testing - N_MGR['json'].enabled = True + N_MGR["json"].enabled = True # Reset Mock mock_send.reset_mock() # Send our service with the `json://` denied - with override_settings(APPRISE_ALLOW_SERVICES=""): - # Test our stateless storage setting (just to kill 2 birds with 1 stone) - with override_settings(APPRISE_STATELESS_STORAGE="yes"): - 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', - ) + with override_settings(APPRISE_ALLOW_SERVICES=""), \ + override_settings(APPRISE_STATELESS_STORAGE="yes"), \ + override_settings(APPRISE_DENY_SERVICES="json"): - # json:// is disabled - assert response.status_code == 204 - assert mock_send.call_count == 0 + # Send our notification as a JSON object + response = self.client.post( + "/notify", + data=json.dumps(json_data), + content_type="application/json", + ) - # What actually took place behind close doors: - assert N_MGR['json'].enabled is False + # json:// is disabled + assert response.status_code == 204 + assert mock_send.call_count == 0 - # Reset our flag (for next test) - N_MGR['json'].enabled = True + # What actually took place behind close doors: + assert N_MGR["json"].enabled is False + + # Reset our flag (for next test) + N_MGR["json"].enabled = True # Reset Mock mock_send.reset_mock() # Send our service with the `json://` denied - with override_settings(APPRISE_ALLOW_SERVICES=""): - with 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', - ) + with override_settings(APPRISE_ALLOW_SERVICES=""), \ + override_settings(APPRISE_DENY_SERVICES="invalid, syslog"): - # json:// is enabled - assert response.status_code == 200 - assert mock_send.call_count == 1 + # Send our notification as a JSON object + response = self.client.post( + "/notify", + data=json.dumps(json_data), + content_type="application/json", + ) - # Verify that json was never turned off - assert N_MGR['json'].enabled is True + # json:// is enabled + 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 mock_send.reset_mock() # Send our service with the `json://` being the only accepted type - with override_settings(APPRISE_ALLOW_SERVICES="json"): - with override_settings(APPRISE_DENY_SERVICES=""): - # Send our notification as a JSON object - response = self.client.post( - '/notify', - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES="json"), override_settings(APPRISE_DENY_SERVICES=""): + # Send our notification as a JSON object + response = self.client.post( + "/notify", + data=json.dumps(json_data), + content_type="application/json", + ) - # json:// is enabled - assert response.status_code == 200 - assert mock_send.call_count == 1 + # json:// is enabled + assert response.status_code == 200 + assert mock_send.call_count == 1 - # Verify email was never turned off - assert N_MGR['json'].enabled is True + # Verify email was never turned off + assert N_MGR["json"].enabled is True # Reset Mock mock_send.reset_mock() # Send our service with the `json://` being the only accepted type - with override_settings(APPRISE_ALLOW_SERVICES="invalid, jsons"): - with override_settings(APPRISE_DENY_SERVICES=""): - # Send our notification as a JSON object - response = self.client.post( - '/notify', - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES="invalid, jsons"), \ + override_settings(APPRISE_DENY_SERVICES=""): + # Send our notification as a JSON object + response = self.client.post( + "/notify", + data=json.dumps(json_data), + content_type="application/json", + ) - # json:// is enabled - assert response.status_code == 200 - assert mock_send.call_count == 1 + # json:// is enabled + assert response.status_code == 200 + assert mock_send.call_count == 1 - # Verify email was never turned off - assert N_MGR['json'].enabled is True + # Verify email was never turned off + assert N_MGR["json"].enabled is True # Reset Mock mock_send.reset_mock() # Send our service with the `json://` being the only accepted type - with override_settings(APPRISE_ALLOW_SERVICES="syslog"): - with override_settings(APPRISE_DENY_SERVICES=""): - # Send our notification as a JSON object - response = self.client.post( - '/notify', - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES="syslog"), override_settings(APPRISE_DENY_SERVICES=""): + # Send our notification as a JSON object + response = self.client.post( + "/notify", + data=json.dumps(json_data), + content_type="application/json", + ) - # json:// is disabled - assert response.status_code == 204 - assert mock_send.call_count == 0 + # json:// is disabled + assert response.status_code == 204 + assert mock_send.call_count == 0 - # What actually took place behind close doors: - assert N_MGR['json'].enabled is False + # What actually took place behind close doors: + assert N_MGR["json"].enabled is False - # Reset our flag (for next test) - N_MGR['json'].enabled = True + # Reset our flag (for next test) + N_MGR["json"].enabled = True # Reset Mock mock_send.reset_mock() # Test case where there is simply no over-rides defined - with override_settings(APPRISE_ALLOW_SERVICES=""): - with override_settings(APPRISE_DENY_SERVICES=""): - # Send our notification as a JSON object - response = self.client.post( - '/notify', - data=json.dumps(json_data), - content_type='application/json', - ) + with override_settings(APPRISE_ALLOW_SERVICES=""), override_settings(APPRISE_DENY_SERVICES=""): + # Send our notification as a JSON object + response = self.client.post( + "/notify", + data=json.dumps(json_data), + content_type="application/json", + ) - # json:// is disabled - assert response.status_code == 200 - assert mock_send.call_count == 1 + # json:// is disabled + assert response.status_code == 200 + assert mock_send.call_count == 1 - # nothing was changed - assert N_MGR['json'].enabled is True + # nothing was changed + assert N_MGR["json"].enabled is True diff --git a/apprise_api/api/tests/test_theme_middleware.py b/apprise_api/api/tests/test_theme_middleware.py new file mode 100644 index 0000000..60340b9 --- /dev/null +++ b/apprise_api/api/tests/test_theme_middleware.py @@ -0,0 +1,43 @@ +# Copyright (C) 2025 Chris Caron +# 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) diff --git a/apprise_api/api/tests/test_urlfilter.py b/apprise_api/api/tests/test_urlfilter.py index c97cde2..82ea8d9 100644 --- a/apprise_api/api/tests/test_urlfilter.py +++ b/apprise_api/api/tests/test_urlfilter.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2025 Chris Caron # All rights reserved. @@ -23,6 +22,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. from django.test import SimpleTestCase + from ..urlfilter import AppriseURLFilter @@ -32,289 +32,295 @@ class AttachmentTests(SimpleTestCase): Test the apprise url filter """ # empty allow and deny lists - af = AppriseURLFilter('', '') + af = AppriseURLFilter("", "") # Test garbage entries - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # 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 # - af = AppriseURLFilter('*', '') + af = AppriseURLFilter("*", "") # We still block junk - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # We however allow localhost now (caught with *) - self.assertTrue(af.is_allowed('http://localhost')) - self.assertTrue(af.is_allowed('http://localhost/resources')) - self.assertTrue(af.is_allowed('http://localhost/images')) + self.assertTrue(af.is_allowed("http://localhost")) + self.assertTrue(af.is_allowed("http://localhost/resources")) + self.assertTrue(af.is_allowed("http://localhost/images")) # # 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 - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # Takeaway is https:// was blocked to resources, but not http:// request # because it was explicitly identify as so: - self.assertTrue(af.is_allowed('http://localhost')) - self.assertTrue(af.is_allowed('http://localhost/resources')) - 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/sub/path/')) - self.assertTrue(af.is_allowed('http://localhost/images')) + self.assertTrue(af.is_allowed("http://localhost")) + self.assertTrue(af.is_allowed("http://localhost/resources")) + 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/sub/path/")) + self.assertTrue(af.is_allowed("http://localhost/images")) # # Allow list accepts all, except we want to explicitely block both # https://localhost/resources and http://localhost/resources # - af = AppriseURLFilter('*', 'localhost/resources') + af = AppriseURLFilter("*", "localhost/resources") # We still block junk - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # Takeaway is https:// was blocked to resources, but not http:// request # because it was explicitly identify as so: - self.assertTrue(af.is_allowed('http://localhost')) - self.assertFalse(af.is_allowed('http://localhost/resources')) - 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/sub/path/')) - self.assertTrue(af.is_allowed('http://localhost/images')) + self.assertTrue(af.is_allowed("http://localhost")) + self.assertFalse(af.is_allowed("http://localhost/resources")) + 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/sub/path/")) + self.assertTrue(af.is_allowed("http://localhost/images")) # # A more restrictive allow/block list # 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 - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # Explicitly only allows https - self.assertFalse(af.is_allowed('http://localhost')) - self.assertTrue(af.is_allowed('https://localhost')) - self.assertFalse(af.is_allowed('https://localhost:8000')) - self.assertTrue(af.is_allowed('https://localhost/images')) - self.assertFalse(af.is_allowed('https://localhost/resources')) - 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/sub/path')) - self.assertFalse(af.is_allowed('http://not-in-list')) + self.assertFalse(af.is_allowed("http://localhost")) + self.assertTrue(af.is_allowed("https://localhost")) + self.assertFalse(af.is_allowed("https://localhost:8000")) + self.assertTrue(af.is_allowed("https://localhost/images")) + self.assertFalse(af.is_allowed("https://localhost/resources")) + 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/sub/path")) + self.assertFalse(af.is_allowed("http://not-in-list")) # 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 # - af = AppriseURLFilter('localhost, myserver:3000', 'localhost/resources') + af = AppriseURLFilter("localhost, myserver:3000", "localhost/resources") # We still block junk - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # all forms of localhost is allowed (provided there is no port) - self.assertTrue(af.is_allowed('http://localhost')) - self.assertTrue(af.is_allowed('https://localhost')) - self.assertFalse(af.is_allowed('https://localhost:8000')) - self.assertFalse(af.is_allowed('https://localhost:80')) - self.assertFalse(af.is_allowed('https://localhost:443')) - self.assertTrue(af.is_allowed('https://localhost/images')) - self.assertFalse(af.is_allowed('https://localhost/resources')) - self.assertFalse(af.is_allowed('https://localhost/resources/sub/path')) - self.assertFalse(af.is_allowed('http://localhost/resources')) - self.assertTrue(af.is_allowed('http://localhost/resourcesssssssss')) - self.assertFalse(af.is_allowed('http://localhost/resources/sub/path/')) - self.assertFalse(af.is_allowed('http://not-in-list')) + self.assertTrue(af.is_allowed("http://localhost")) + self.assertTrue(af.is_allowed("https://localhost")) + self.assertFalse(af.is_allowed("https://localhost:8000")) + self.assertFalse(af.is_allowed("https://localhost:80")) + self.assertFalse(af.is_allowed("https://localhost:443")) + self.assertTrue(af.is_allowed("https://localhost/images")) + self.assertFalse(af.is_allowed("https://localhost/resources")) + self.assertFalse(af.is_allowed("https://localhost/resources/sub/path")) + self.assertFalse(af.is_allowed("http://localhost/resources")) + self.assertTrue(af.is_allowed("http://localhost/resourcesssssssss")) + self.assertFalse(af.is_allowed("http://localhost/resources/sub/path/")) + self.assertFalse(af.is_allowed("http://not-in-list")) # myserver is only allowed if port is provided - self.assertFalse(af.is_allowed('http://myserver')) - self.assertFalse(af.is_allowed('https://myserver')) - self.assertTrue(af.is_allowed('http://myserver:3000')) - self.assertTrue(af.is_allowed('https://myserver:3000')) + self.assertFalse(af.is_allowed("http://myserver")) + self.assertFalse(af.is_allowed("https://myserver")) + self.assertTrue(af.is_allowed("http://myserver:3000")) + self.assertTrue(af.is_allowed("https://myserver:3000")) # Open range of hosts allows these to be accepted: - self.assertTrue(af.is_allowed('localhost')) - self.assertTrue(af.is_allowed('myserver:3000')) - self.assertTrue(af.is_allowed('https://myserver:3000')) - self.assertTrue(af.is_allowed('http://myserver:3000')) + self.assertTrue(af.is_allowed("localhost")) + self.assertTrue(af.is_allowed("myserver:3000")) + self.assertTrue(af.is_allowed("https://myserver:3000")) + self.assertTrue(af.is_allowed("http://myserver:3000")) # # Testing of hostnames only and ports but via URLs (explicit http://) # 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 - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # http://localhost acceptance only - self.assertTrue(af.is_allowed('http://localhost')) - self.assertFalse(af.is_allowed('https://localhost')) - self.assertFalse(af.is_allowed('http://localhost:8000')) - self.assertFalse(af.is_allowed('http://localhost:80')) - self.assertTrue(af.is_allowed('http://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/sub/path')) - self.assertFalse(af.is_allowed('http://localhost/resources')) - self.assertFalse(af.is_allowed('http://not-in-list')) + self.assertTrue(af.is_allowed("http://localhost")) + self.assertFalse(af.is_allowed("https://localhost")) + self.assertFalse(af.is_allowed("http://localhost:8000")) + self.assertFalse(af.is_allowed("http://localhost:80")) + self.assertTrue(af.is_allowed("http://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/sub/path")) + self.assertFalse(af.is_allowed("http://localhost/resources")) + self.assertFalse(af.is_allowed("http://not-in-list")) # myserver is only allowed if port is provided and http:// - self.assertFalse(af.is_allowed('http://myserver')) - self.assertFalse(af.is_allowed('https://myserver')) - self.assertTrue(af.is_allowed('http://myserver:3000')) - self.assertFalse(af.is_allowed('https://myserver:3000')) - self.assertTrue(af.is_allowed('http://myserver:3000/path/')) + self.assertFalse(af.is_allowed("http://myserver")) + self.assertFalse(af.is_allowed("https://myserver")) + self.assertTrue(af.is_allowed("http://myserver:3000")) + self.assertFalse(af.is_allowed("https://myserver:3000")) + self.assertTrue(af.is_allowed("http://myserver:3000/path/")) # Open range of hosts is no longer allowed due to explicit http:// reference - self.assertFalse(af.is_allowed('localhost')) - self.assertFalse(af.is_allowed('myserver:3000')) - self.assertFalse(af.is_allowed('https://myserver:3000')) + self.assertFalse(af.is_allowed("localhost")) + self.assertFalse(af.is_allowed("myserver:3000")) + self.assertFalse(af.is_allowed("https://myserver:3000")) # # Testing of hostnames only and ports but via URLs (explicit https://) # 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 - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # http://localhost acceptance only - self.assertTrue(af.is_allowed('https://localhost')) - self.assertFalse(af.is_allowed('http://localhost')) - self.assertFalse(af.is_allowed('localhost')) - self.assertFalse(af.is_allowed('https://localhost:8000')) - self.assertFalse(af.is_allowed('https://localhost:80')) - self.assertTrue(af.is_allowed('https://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/sub/path')) - self.assertFalse(af.is_allowed('https://localhost/resources')) - self.assertFalse(af.is_allowed('https://not-in-list')) + self.assertTrue(af.is_allowed("https://localhost")) + self.assertFalse(af.is_allowed("http://localhost")) + self.assertFalse(af.is_allowed("localhost")) + self.assertFalse(af.is_allowed("https://localhost:8000")) + self.assertFalse(af.is_allowed("https://localhost:80")) + self.assertTrue(af.is_allowed("https://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/sub/path")) + self.assertFalse(af.is_allowed("https://localhost/resources")) + self.assertFalse(af.is_allowed("https://not-in-list")) # myserver is only allowed if port is provided and http:// - self.assertFalse(af.is_allowed('https://myserver')) - self.assertFalse(af.is_allowed('http://myserver')) - self.assertFalse(af.is_allowed('myserver')) - self.assertTrue(af.is_allowed('https://myserver:3000')) - self.assertFalse(af.is_allowed('http://myserver:3000')) - self.assertTrue(af.is_allowed('https://myserver:3000/path/')) + self.assertFalse(af.is_allowed("https://myserver")) + self.assertFalse(af.is_allowed("http://myserver")) + self.assertFalse(af.is_allowed("myserver")) + self.assertTrue(af.is_allowed("https://myserver:3000")) + self.assertFalse(af.is_allowed("http://myserver:3000")) + self.assertTrue(af.is_allowed("https://myserver:3000/path/")) # Open range of hosts is no longer allowed due to explicit http:// reference - self.assertFalse(af.is_allowed('localhost')) - self.assertFalse(af.is_allowed('myserver:3000')) - self.assertFalse(af.is_allowed('http://myserver:3000')) + self.assertFalse(af.is_allowed("localhost")) + self.assertFalse(af.is_allowed("myserver:3000")) + self.assertFalse(af.is_allowed("http://myserver:3000")) # # Testing Regular Expressions # - af = AppriseURLFilter('https://localhost/incoming/*/*', 'https://localhost/*/*/var') + af = AppriseURLFilter("https://localhost/incoming/*/*", "https://localhost/*/*/var") # We still block junk - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # Very specific paths are supported now in https://localhost only: - self.assertFalse(af.is_allowed('https://localhost')) - self.assertFalse(af.is_allowed('http://localhost')) - 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")) + self.assertFalse(af.is_allowed("http://localhost")) + 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.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.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/")) # 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/sub/dir')) - self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/var/sub')) + 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")) # Test the ? out - af = AppriseURLFilter('localhost?', '') + af = AppriseURLFilter("localhost?", "") # Test garbage entries - self.assertFalse(af.is_allowed('$')) - self.assertFalse(af.is_allowed(b'13')) - self.assertFalse(af.is_allowed(u'Mālō e Lelei')) - self.assertFalse(af.is_allowed('')) + self.assertFalse(af.is_allowed("$")) + self.assertFalse(af.is_allowed(b"13")) + self.assertFalse(af.is_allowed("Mālō e Lelei")) + self.assertFalse(af.is_allowed("")) self.assertFalse(af.is_allowed(None)) self.assertFalse(af.is_allowed(True)) self.assertFalse(af.is_allowed(42)) # These are blocked too since we have no allow list - self.assertFalse(af.is_allowed('http://localhost')) - self.assertTrue(af.is_allowed('http://localhost1')) - self.assertTrue(af.is_allowed('https://localhost1')) - self.assertFalse(af.is_allowed('http://localhost%')) - self.assertFalse(af.is_allowed('http://localhost10')) + self.assertFalse(af.is_allowed("http://localhost")) + self.assertTrue(af.is_allowed("http://localhost1")) + self.assertTrue(af.is_allowed("https://localhost1")) + self.assertFalse(af.is_allowed("http://localhost%")) + self.assertFalse(af.is_allowed("http://localhost10")) # conflicting elements cancel one another - af = AppriseURLFilter('localhost', 'localhost') + af = AppriseURLFilter("localhost", "localhost") # These are blocked too since we have no allow list - self.assertFalse(af.is_allowed('localhost')) + self.assertFalse(af.is_allowed("localhost")) diff --git a/apprise_api/api/tests/test_utils.py b/apprise_api/api/tests/test_utils.py index 4954b9c..5be3b44 100644 --- a/apprise_api/api/tests/test_utils.py +++ b/apprise_api/api/tests/test_utils.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2024 Chris Caron # All rights reserved. @@ -23,41 +22,41 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import os -import mock import tempfile +from unittest import mock + from django.test import SimpleTestCase + from .. import utils class UtilsTests(SimpleTestCase): - def test_touchdir(self): """ Test touchdir() """ with tempfile.TemporaryDirectory() as tmpdir: - with mock.patch('os.makedirs', side_effect=OSError()): - assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False + with mock.patch("os.makedirs", side_effect=OSError()): + 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 - 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 - 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 - 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): """ Test touch() """ - with tempfile.TemporaryDirectory() as tmpdir: - with mock.patch('os.fdopen', side_effect=OSError()): - assert utils.touch(os.path.join(tmpdir, 'tmp-file')) is False + with tempfile.TemporaryDirectory() as tmpdir, mock.patch("os.fdopen", side_effect=OSError()): + assert utils.touch(os.path.join(tmpdir, "tmp-file")) is False diff --git a/apprise_api/api/tests/test_webhook.py b/apprise_api/api/tests/test_webhook.py index 51ce44a..db41d37 100644 --- a/apprise_api/api/tests/test_webhook.py +++ b/apprise_api/api/tests/test_webhook.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,17 +21,18 @@ # 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.test import SimpleTestCase -from unittest import mock from json import loads -import requests -from ..utils import send_webhook +from unittest import mock + +from django.test import SimpleTestCase from django.test.utils import override_settings +import requests + +from ..utils import send_webhook class WebhookTests(SimpleTestCase): - - @mock.patch('requests.post') + @mock.patch("requests.post") def test_webhook_testing(self, mock_post): """ Test webhook handling @@ -43,48 +43,45 @@ class WebhookTests(SimpleTestCase): response.status_code = requests.codes.ok mock_post.return_value = response - with override_settings( - APPRISE_WEBHOOK_URL='https://' - 'user:pass@localhost/webhook'): + with override_settings(APPRISE_WEBHOOK_URL="https://user:pass@localhost/webhook"): send_webhook({}) assert mock_post.call_count == 1 details = mock_post.call_args_list[0] - assert details[0][0] == 'https://localhost/webhook' - assert loads(details[1]['data']) == {} - assert 'User-Agent' in details[1]['headers'] - assert 'Content-Type' in details[1]['headers'] - assert details[1]['headers']['User-Agent'] == 'Apprise-API' - assert details[1]['headers']['Content-Type'] == 'application/json' - assert details[1]['auth'] == ('user', 'pass') - assert details[1]['verify'] is True - assert details[1]['params'] == {} - assert details[1]['timeout'] == (4.0, 4.0) + assert details[0][0] == "https://localhost/webhook" + assert loads(details[1]["data"]) == {} + assert "User-Agent" in details[1]["headers"] + assert "Content-Type" in details[1]["headers"] + assert details[1]["headers"]["User-Agent"] == "Apprise-API" + assert details[1]["headers"]["Content-Type"] == "application/json" + assert details[1]["auth"] == ("user", "pass") + assert details[1]["verify"] is True + assert details[1]["params"] == {} + assert details[1]["timeout"] == (4.0, 4.0) mock_post.reset_mock() with override_settings( - APPRISE_WEBHOOK_URL='http://' - 'user@localhost/webhook/here' - '?verify=False&key=value&cto=2.0&rto=1.0'): + APPRISE_WEBHOOK_URL="http://user@localhost/webhook/here?verify=False&key=value&cto=2.0&rto=1.0" + ): send_webhook({}) assert mock_post.call_count == 1 details = mock_post.call_args_list[0] - assert details[0][0] == 'http://localhost/webhook/here' - assert loads(details[1]['data']) == {} - assert 'User-Agent' in details[1]['headers'] - assert 'Content-Type' in details[1]['headers'] - assert details[1]['headers']['User-Agent'] == 'Apprise-API' - assert details[1]['headers']['Content-Type'] == 'application/json' - assert details[1]['auth'] == ('user', None) - assert details[1]['verify'] is False - assert details[1]['params'] == {'key': 'value'} - assert details[1]['timeout'] == (2.0, 1.0) + assert details[0][0] == "http://localhost/webhook/here" + assert loads(details[1]["data"]) == {} + assert "User-Agent" in details[1]["headers"] + assert "Content-Type" in details[1]["headers"] + assert details[1]["headers"]["User-Agent"] == "Apprise-API" + assert details[1]["headers"]["Content-Type"] == "application/json" + assert details[1]["auth"] == ("user", None) + assert details[1]["verify"] is False + assert details[1]["params"] == {"key": "value"} + assert details[1]["timeout"] == (2.0, 1.0) mock_post.reset_mock() - with override_settings(APPRISE_WEBHOOK_URL='invalid'): + with override_settings(APPRISE_WEBHOOK_URL="invalid"): # Invalid webhook defined send_webhook({}) assert mock_post.call_count == 0 @@ -98,15 +95,14 @@ class WebhookTests(SimpleTestCase): mock_post.reset_mock() - with override_settings(APPRISE_WEBHOOK_URL='http://$#@'): + with override_settings(APPRISE_WEBHOOK_URL="http://$#@"): # Invalid hostname defined send_webhook({}) assert mock_post.call_count == 0 mock_post.reset_mock() - with override_settings( - APPRISE_WEBHOOK_URL='invalid://hostname'): + with override_settings(APPRISE_WEBHOOK_URL="invalid://hostname"): # Invalid webhook defined send_webhook({}) assert mock_post.call_count == 0 @@ -116,9 +112,7 @@ class WebhookTests(SimpleTestCase): # A valid URL with a bad server response: response.status_code = requests.codes.internal_server_error mock_post.return_value = response - with override_settings( - APPRISE_WEBHOOK_URL='http://localhost'): - + with override_settings(APPRISE_WEBHOOK_URL="http://localhost"): send_webhook({}) assert mock_post.call_count == 1 @@ -127,8 +121,6 @@ class WebhookTests(SimpleTestCase): # A valid URL with a bad server response: mock_post.return_value = None mock_post.side_effect = requests.RequestException("error") - with override_settings( - APPRISE_WEBHOOK_URL='http://localhost'): - + with override_settings(APPRISE_WEBHOOK_URL="http://localhost"): send_webhook({}) assert mock_post.call_count == 1 diff --git a/apprise_api/api/tests/test_welcome.py b/apprise_api/api/tests/test_welcome.py index bb5aac0..c7a77b5 100644 --- a/apprise_api/api/tests/test_welcome.py +++ b/apprise_api/api/tests/test_welcome.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -26,7 +25,6 @@ from django.test import SimpleTestCase class WelcomePageTests(SimpleTestCase): - def test_welcome_page_status_code(self): - response = self.client.get('/') + response = self.client.get("/") assert response.status_code == 200 diff --git a/apprise_api/api/urlfilter.py b/apprise_api/api/urlfilter.py index 11c7fcc..1132fe5 100644 --- a/apprise_api/api/urlfilter.py +++ b/apprise_api/api/urlfilter.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2023 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # 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 # THE SOFTWARE. import re + 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. Tokens are classified as follows: - - URL‐based tokens: if they start with “http://” or “https://” (explicit) + - URL-based tokens: if they start with “http://” or “https://” (explicit) or if they contain a “/” (implicit; no scheme given). - - Host‐based tokens: those that do not contain a “/”. + - Host-based tokens: those that do not contain a “/”. Returns a list of tuples (compiled_regex, is_url_based). """ - tokens = re.split(r'[\s,]+', list_str.strip().lower()) + tokens = re.split(r"[\s,]+", list_str.strip().lower()) rules = [] for token in tokens: if not token: @@ -88,7 +88,7 @@ class AppriseURLFilter: def _compile_url_token(self, token: str): """ - Compiles a URL‐based token (explicit token that starts with a scheme) into a regex. + Compiles a URL-based token (explicit token that starts with a scheme) into a regex. An implied trailing wildcard is added to the path: - 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. @@ -99,18 +99,18 @@ class AppriseURLFilter: # Determine the scheme. scheme_regex = "" if token.startswith("http://"): - scheme_regex = r'http' + scheme_regex = r"http" # drop http:// token = token[7:] elif token.startswith("https://"): - scheme_regex = r'https' + scheme_regex = r"https" # drop https:// token = token[8:] else: # https? # Used for implicit tokens; our _compile_implicit_token ensures this. - scheme_regex = r'https?' + scheme_regex = r"https?" # strip https?:// token = token[9:] @@ -173,8 +173,8 @@ class AppriseURLFilter: def _compile_host_token(self, token: str): """ - Compiles a host‐based token (one with no “/”) into a regex. - Note: When matching host‐based tokens, we require that the URL’s scheme is exactly “http”. + Compiles a host-based token (one with no "/") into a regex. + Note: When matching host-based tokens, we require that the URL's scheme is exactly "http". """ regex = "^" + self._wildcard_to_regex(token) + "$" return re.compile(regex, re.IGNORECASE) @@ -190,11 +190,11 @@ class AppriseURLFilter: """ regex = "" for char in pattern: - if char == '*': - regex += r"[^/]+/?" if not is_host else r'.*' + if char == "*": + regex += r"[^/]+/?" if not is_host else r".*" - elif char == '?': - regex += r'[^/]' if not is_host else r"[A-Za-z0-9_-]" + elif char == "?": + regex += r"[^/]" if not is_host else r"[A-Za-z0-9_-]" else: regex += re.escape(char) @@ -205,14 +205,15 @@ class AppriseURLFilter: """ 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 host-based rules, the URL’s netloc (which includes the port) is tested. + For host-based rules, the URL's netloc (which includes the port) is tested. """ parsed = parse_url(url, strict_port=True, simple=True) if not parsed: return False # 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. for pattern, is_url_based in self.deny_rules: diff --git a/apprise_api/api/urls.py b/apprise_api/api/urls.py index 0f24ddd..95832d4 100644 --- a/apprise_api/api/urls.py +++ b/apprise_api/api/urls.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -23,40 +22,31 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. from django.urls import re_path + from . import views 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( - r'^$', - views.WelcomeView.as_view(), name='welcome'), + r"^cfg/(?P[\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[\w_-]{1,128})/?$", views.AddView.as_view(), name="add"), + re_path(r"^del/(?P[\w_-]{1,128})/?$", views.DelView.as_view(), name="del"), + re_path(r"^get/(?P[\w_-]{1,128})/?$", views.GetView.as_view(), name="get"), re_path( - r'^status/?$', - views.HealthCheckView.as_view(), name='health'), + r"^notify/(?P[\w_-]{1,128})/?$", + views.NotifyView.as_view(), + name="notify", + ), + re_path(r"^notify/?$", views.StatelessNotifyView.as_view(), name="s_notify"), re_path( - r'^details/?$', - views.DetailsView.as_view(), name='details'), - re_path( - r'^cfg/(?P[\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[\w_-]{1,128})/?$', - views.AddView.as_view(), name='add'), - re_path( - r'^del/(?P[\w_-]{1,128})/?$', - views.DelView.as_view(), name='del'), - re_path( - r'^get/(?P[\w_-]{1,128})/?$', - views.GetView.as_view(), name='get'), - re_path( - r'^notify/(?P[\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[\w_-]{1,128})/?$', - views.JsonUrlView.as_view(), name='json_urls'), + r"^json/urls/(?P[\w_-]{1,128})/?$", + views.JsonUrlView.as_view(), + name="json_urls", + ), ] diff --git a/apprise_api/api/utils.py b/apprise_api/api/utils.py index 421e99b..9d6bea1 100644 --- a/apprise_api/api/utils.py +++ b/apprise_api/api/utils.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,54 +21,60 @@ # 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. -import re -import binascii -import os -import tempfile -import shutil -import gzip -import apprise -import hashlib -import errno import base64 -import requests -from json import dumps -from django.conf import settings +import binascii +from contextlib import suppress from datetime import datetime -from . urlfilter import AppriseURLFilter +import errno +import gzip +import hashlib +from json import dumps # import the logging library 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 -logger = logging.getLogger('django') +logger = logging.getLogger("django") -class AppriseStoreMode(object): +class AppriseStoreMode: """ Defines the store modes of configuration """ + # This is the default option. Content is cached and written by # it's key - HASH = 'hash' + HASH = "hash" # Content is written straight to disk using it's key # there is nothing further done - SIMPLE = 'simple' + SIMPLE = "simple" # When set to disabled; stateful functionality is disabled - DISABLED = 'disabled' + DISABLED = "disabled" -class AttachmentPayload(object): +class AttachmentPayload: """ Defines the supported Attachment Payload Types """ + # BASE64 - BASE64 = 'base64' + BASE64 = "base64" # URL request - URL = 'url' + URL = "url" 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) -class Attachment(A_MGR['file']): +class Attachment(A_MGR["file"]): """ A Light Weight Attachment Object for Auto-cleanup that wraps the Apprise Attachments @@ -106,8 +111,7 @@ class Attachment(A_MGR['file']): except OSError: # Permission error - raise ValueError('Could not create directory {}'.format( - settings.APPRISE_ATTACH_DIR)) + raise ValueError("Could not create directory {}".format(settings.APPRISE_ATTACH_DIR)) from None if not path: try: @@ -117,8 +121,8 @@ class Attachment(A_MGR['file']): except FileNotFoundError: raise ValueError( - 'Could not prepare {} attachment in {}'.format( - filename, settings.APPRISE_ATTACH_DIR)) + "Could not prepare {} attachment in {}".format(filename, settings.APPRISE_ATTACH_DIR) + ) from None self._path = path @@ -144,14 +148,12 @@ class Attachment(A_MGR['file']): De-Construtor is used to tidy up files during garbage collection """ if self.delete and self._path: - try: + # no problem if file is missing + with suppress(FileNotFoundError): 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 Web Attachments @@ -169,8 +171,7 @@ class HTTPAttachment(A_MGR['http']): except OSError: # Permission error - raise ValueError('Could not create directory {}'.format( - settings.APPRISE_ATTACH_DIR)) + raise ValueError("Could not create directory {}".format(settings.APPRISE_ATTACH_DIR)) from None try: d, self._path = tempfile.mkstemp(dir=settings.APPRISE_ATTACH_DIR) @@ -179,8 +180,8 @@ class HTTPAttachment(A_MGR['http']): except FileNotFoundError: raise ValueError( - 'Could not prepare {} attachment in {}'.format( - filename, settings.APPRISE_ATTACH_DIR)) + "Could not prepare {} attachment in {}".format(filename, settings.APPRISE_ATTACH_DIR) + ) from None # Prepare our item 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 """ if self.delete and self._path: - try: + # no problem if file is missing + with suppress(FileNotFoundError): os.remove(self._path) - except FileNotFoundError: - # no problem - pass 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 try: 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, - dir_fd=None if os.supports_fd else dir_fd, **kwargs) + os.utime( + f.fileno() if os.utime in os.supports_fd else fname, + dir_fd=None if os.supports_fd else dir_fd, + **kwargs, + ) except OSError: return False @@ -266,26 +268,25 @@ def parse_attachments(attachment_payload, files_request): raise ValueError("Attachment support has been disabled") # Attachment Count - count = sum([ - 0 if not isinstance(attachment_payload, (set, tuple, list)) - else len(attachment_payload), - 0 if not isinstance(files_request, dict) else len(files_request), - ]) + count = sum( + [ + (0 if not isinstance(attachment_payload, set | tuple | list) else len(attachment_payload)), + 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 - attachment_payload = (attachment_payload, ) + attachment_payload = (attachment_payload,) count += 1 if settings.APPRISE_MAX_ATTACHMENTS > 0 and count > settings.APPRISE_MAX_ATTACHMENTS: - raise ValueError( - "There is a maximum of %d attachments" % - settings.APPRISE_MAX_ATTACHMENTS) + raise ValueError(f"There is a maximum of {settings.APPRISE_MAX_ATTACHMENTS} attachments") - if isinstance(attachment_payload, (tuple, list, set)): + if isinstance(attachment_payload, tuple | list | set): for no, entry in enumerate(attachment_payload, start=1): - if isinstance(entry, (str, bytes)): - filename = "attachment.%.3d" % no + if isinstance(entry, str | bytes): + filename = f"attachment.{no:03d}" elif isinstance(entry, dict): try: @@ -293,24 +294,19 @@ def parse_attachments(attachment_payload, files_request): # Max filename size is 250 if len(filename) > 250: - raise ValueError( - "The filename associated with attachment " - "%d is too long" % no) + raise ValueError(f"The filename associated with attachment {no} is too long") elif not filename: - filename = "attachment.%.3d" % no + filename = f"attachment.{no:03d}" except AttributeError: # not a string that was provided - raise ValueError( - "An invalid filename was provided for attachment %d" % - no) + raise ValueError(f"An invalid filename was provided for attachment {no}") from None else: # you must pass in a base64 string, or a dict containing our # required parameters - raise ValueError( - "An invalid filename was provided for attachment %d" % no) + raise ValueError(f"An invalid filename was provided for attachment {no}") # # Prepare our Attachment @@ -324,82 +320,62 @@ def parse_attachments(attachment_payload, files_request): count -= 1 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 - raise ValueError( - "Failed to load attachment " - "%d (not web request): %s" % (no, entry)) + raise ValueError(f"Failed to load attachment {no} (not web request): {entry}") if not ATTACH_URL_FILTER.is_allowed(entry): # We are not allowed to use this entry - raise ValueError( - "Denied attachment " - "%d (blocked web request): %s" % (no, entry)) + raise ValueError(f"Denied attachment {no} (blocked web request): {entry}") - attachment = HTTPAttachment( - filename, **A_MGR['http'].parse_url(entry)) + attachment = HTTPAttachment(filename, **A_MGR["http"].parse_url(entry)) if not attachment: # We failed to retrieve the attachment - raise ValueError( - "Failed to retrieve attachment %d: %s" % (no, entry)) + raise ValueError(f"Failed to retrieve attachment {no}: {entry}") - else: # web, base64 or raw + else: # web, base64 or raw attachment = Attachment(filename) try: - with open(attachment.path, 'wb') as f: + with open(attachment.path, "wb") as f: # Write our content to disk - if isinstance(entry, dict) and \ - AttachmentPayload.BASE64 in entry: + if isinstance(entry, dict) and AttachmentPayload.BASE64 in entry: # BASE64 - f.write( - base64.b64decode( - entry[AttachmentPayload.BASE64])) - - elif isinstance(entry, dict) and \ - AttachmentPayload.URL in entry: + f.write(base64.b64decode(entry[AttachmentPayload.BASE64])) + elif isinstance(entry, dict) and AttachmentPayload.URL in entry: if not ATTACH_URL_FILTER.is_allowed(entry[AttachmentPayload.URL]): # We are not allowed to use this entry raise ValueError( - "Denied attachment " - "%d (blocked web request): %s" % ( - no, entry[AttachmentPayload.URL])) + f"Denied attachment {no} (blocked web request): {entry[AttachmentPayload.URL]}" + ) attachment = HTTPAttachment( - filename, **A_MGR['http'] - .parse_url(entry[AttachmentPayload.URL])) + filename, + **A_MGR["http"].parse_url(entry[AttachmentPayload.URL]), + ) if not attachment: # We failed to retrieve the attachment - raise ValueError( - "Failed to retrieve attachment " - "%d: %s" % (no, entry)) + raise ValueError(f"Failed to retrieve attachment {no}: {entry}") elif isinstance(entry, bytes): # RAW f.write(entry) else: - raise ValueError( - "Invalid filetype was provided for " - "attachment %s" % filename) + raise ValueError(f"Invalid filetype was provided for attachment {filename}") except binascii.Error: # The file ws not base64 encoded - raise ValueError( - "Invalid filecontent was provided for attachment %s" % - filename) + raise ValueError(f"Invalid filecontent was provided for attachment {filename}") from None except OSError: - raise ValueError( - "Could not write attachment %s to disk" % filename) + raise ValueError(f"Could not write attachment {filename} to disk") from None # # Some Validation # - if settings.APPRISE_ATTACH_SIZE > 0 and \ - attachment.size > settings.APPRISE_ATTACH_SIZE: - raise ValueError( - "attachment %s's filesize is to large" % filename) + if settings.APPRISE_ATTACH_SIZE > 0 and attachment.size > settings.APPRISE_ATTACH_SIZE: + raise ValueError(f"attachment {filename}'s filesize is to large") # Add our attachment attachments.append(attachment) @@ -408,9 +384,7 @@ def parse_attachments(attachment_payload, files_request): # Now handle the request.FILES # if isinstance(files_request, dict): - for no, (key, meta) in enumerate( - files_request.items(), start=len(attachments) + 1): - + for no, (_key, meta) in enumerate(files_request.items(), start=len(attachments) + 1): try: # Filetype is presumed to be of base class # django.core.files.UploadedFile @@ -418,37 +392,31 @@ def parse_attachments(attachment_payload, files_request): # Max filename size is 250 if len(filename) > 250: - raise ValueError( - "The filename associated with attachment " - "%d is too long" % no) + raise ValueError(f"The filename associated with attachment {no} is too long") elif not filename: - filename = "attachment.%.3d" % no + filename = f"attachment.{no:03d}" except (AttributeError, TypeError): - raise ValueError( - "An invalid filename was provided for attachment %d" % no) + raise ValueError(f"An invalid filename was provided for attachment {no}") from None # # Prepare our Attachment # attachment = Attachment(filename) try: - with open(attachment.path, 'wb') as f: + with open(attachment.path, "wb") as f: # Write our content to disk f.write(meta.read()) except OSError: - raise ValueError( - "Could not write attachment %s to disk" % filename) + raise ValueError(f"Could not write attachment {filename} to disk") from None # # Some Validation # - if settings.APPRISE_ATTACH_SIZE > 0 and \ - attachment.size > settings.APPRISE_ATTACH_SIZE: - raise ValueError( - "attachment %s's filesize is to large" % filename) + if settings.APPRISE_ATTACH_SIZE > 0 and attachment.size > settings.APPRISE_ATTACH_SIZE: + raise ValueError(f"attachment {filename}'s filesize is to large") # Add our attachment attachments.append(attachment) @@ -456,20 +424,21 @@ def parse_attachments(attachment_payload, files_request): return attachments -class SimpleFileExtension(object): +class SimpleFileExtension: """ Defines the simple file exension lookups """ + # Simple Configuration file - TEXT = 'cfg' + TEXT = "cfg" # YAML Configuration file - YAML = 'yml' + YAML = "yml" SIMPLE_FILE_EXTENSION_MAPPING = { - apprise.ConfigFormat.TEXT: SimpleFileExtension.TEXT, - apprise.ConfigFormat.YAML: SimpleFileExtension.YAML, + apprise.ConfigFormat.TEXT.value: SimpleFileExtension.TEXT, + apprise.ConfigFormat.YAML.value: SimpleFileExtension.YAML, SimpleFileExtension.TEXT: SimpleFileExtension.TEXT, SimpleFileExtension.YAML: SimpleFileExtension.YAML, } @@ -477,7 +446,7 @@ SIMPLE_FILE_EXTENSION_MAPPING = { 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 type structure that is fast. @@ -492,9 +461,7 @@ class AppriseConfigCache(object): self.mode = mode.strip().lower() if self.mode not in STORE_MODES: self.mode = AppriseStoreMode.DISABLED - logger.error( - 'APPRISE_STATEFUL_MODE {} is not supported; ' - 'reverted to {}.'.format(mode, self.mode)) + logger.error("APPRISE_STATEFUL_MODE {} is not supported; reverted to {}.".format(mode, self.mode)) def put(self, key, content, fmt): """ @@ -519,18 +486,18 @@ class AppriseConfigCache(object): except OSError: # Permission error - logger.error('Could not create directory {}'.format(path)) + logger.error("Could not create directory {}".format(path)) return False # 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() # We're reopening it, and it can't be renamed while open on Windows os.close(d) if self.mode == AppriseStoreMode.HASH: try: - with gzip.open(tmp_path, 'wb') as f: + with gzip.open(tmp_path, "wb") as f: # Write our content to disk f.write(content.encode()) @@ -543,7 +510,7 @@ class AppriseConfigCache(object): # Update our file extenion based on our fmt fmt = SIMPLE_FILE_EXTENSION_MAPPING[fmt] try: - with open(tmp_path, 'wb') as f: + with open(tmp_path, "wb") as f: # Write our content to disk f.write(content.encode()) @@ -555,8 +522,7 @@ class AppriseConfigCache(object): # If we reach here we successfully wrote the content. We now safely # move our configuration into place. The following writes our content # to disk - shutil.move(tmp_path, os.path.join( - path, '{}.{}'.format(filename, fmt))) + shutil.move(tmp_path, os.path.join(path, "{}.{}".format(filename, fmt))) # perform tidy of any other lingering files of other type in case # configuration changed from TEXT -> YAML or YAML -> TEXT @@ -593,7 +559,7 @@ class AppriseConfigCache(object): if self.mode == AppriseStoreMode.DISABLED: # Do nothing - return (None, '') + return (None, "") # There isn't a lot of error handling done here as it is presumed most # 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 if self.mode == AppriseStoreMode.HASH: - text_file = os.path.join( - path, '{}.{}'.format(filename, apprise.ConfigFormat.TEXT)) - yaml_file = os.path.join( - path, '{}.{}'.format(filename, apprise.ConfigFormat.YAML)) + text_file = os.path.join(path, "{}.{}".format(filename, apprise.ConfigFormat.TEXT.value)) + yaml_file = os.path.join(path, "{}.{}".format(filename, apprise.ConfigFormat.YAML.value)) else: # AppriseStoreMode.SIMPLE - text_file = os.path.join( - path, '{}.{}'.format(filename, SimpleFileExtension.TEXT)) - yaml_file = os.path.join( - path, '{}.{}'.format(filename, SimpleFileExtension.YAML)) + text_file = os.path.join(path, "{}.{}".format(filename, SimpleFileExtension.TEXT)) + yaml_file = os.path.join(path, "{}.{}".format(filename, SimpleFileExtension.YAML)) if os.path.isfile(text_file): - fmt = apprise.ConfigFormat.TEXT + fmt = apprise.ConfigFormat.TEXT.value path = text_file elif os.path.isfile(yaml_file): - fmt = apprise.ConfigFormat.YAML + fmt = apprise.ConfigFormat.YAML.value path = yaml_file else: # Not found; we set the fmt to something other than none as # an indication for the upstream handling to know that we didn't # fail on error - return (None, '') + return (None, "") # Initialize our content content = None if self.mode == AppriseStoreMode.HASH: try: - with gzip.open(path, 'rb') as f: + with gzip.open(path, "rb") as f: # Write our content to disk content = f.read().decode() @@ -646,7 +608,7 @@ class AppriseConfigCache(object): else: # AppriseStoreMode.SIMPLE try: - with open(path, 'rb') as f: + with open(path, "rb") as f: # Write our content to disk content = f.read().decode() @@ -683,10 +645,15 @@ class AppriseConfigCache(object): # Eliminate any existing content if present try: # Handle failure - os.remove(os.path.join(path, '{}.{}'.format( - filename, - fmt if self.mode == AppriseStoreMode.HASH - else SIMPLE_FILE_EXTENSION_MAPPING[fmt]))) + os.remove( + os.path.join( + path, + "{}.{}".format( + filename, + (fmt if self.mode == AppriseStoreMode.HASH else SIMPLE_FILE_EXTENSION_MAPPING[fmt]), + ), + ) + ) # If we reach here, an element was removed response = True @@ -708,7 +675,7 @@ class AppriseConfigCache(object): path = os.path.join(self.root, encoded_key[0:2]) return (path, encoded_key[2:]) - else: # AppriseStoreMode.SIMPLE + else: # AppriseStoreMode.SIMPLE return (self.root, key) def keys(self): @@ -720,7 +687,7 @@ class AppriseConfigCache(object): return keys for filename in sorted(os.listdir(self.root)): - if filename.startswith('.'): + if filename.startswith("."): continue path = os.path.join(self.root, filename) if os.path.isfile(path): @@ -732,8 +699,10 @@ class AppriseConfigCache(object): # Initialize our singleton ConfigCache = AppriseConfigCache( - settings.APPRISE_CONFIG_DIR, salt=settings.SECRET_KEY, - mode=settings.APPRISE_STATEFUL_MODE) + settings.APPRISE_CONFIG_DIR, + salt=settings.SECRET_KEY, + mode=settings.APPRISE_STATEFUL_MODE, +) def apply_global_filters(): @@ -741,22 +710,22 @@ def apply_global_filters(): # Apply Any Global Filters (if identified) # if settings.APPRISE_ALLOW_SERVICES: - alphanum_re = re.compile( - r'^(?P[a-z][a-z0-9]+)', re.IGNORECASE) - entries = \ - [alphanum_re.match(x).group('name').lower() - for x in re.split(r'[ ,]+', settings.APPRISE_ALLOW_SERVICES) - if alphanum_re.match(x)] + alphanum_re = re.compile(r"^(?P[a-z][a-z0-9]+)", re.IGNORECASE) + entries = [ + alphanum_re.match(x).group("name").lower() + for x in re.split(r"[ ,]+", settings.APPRISE_ALLOW_SERVICES) + if alphanum_re.match(x) + ] N_MGR.enable_only(*entries) elif settings.APPRISE_DENY_SERVICES: - alphanum_re = re.compile( - r'^(?P[a-z][a-z0-9]+)', re.IGNORECASE) - entries = \ - [alphanum_re.match(x).group('name').lower() - for x in re.split(r'[ ,]+', settings.APPRISE_DENY_SERVICES) - if alphanum_re.match(x)] + alphanum_re = re.compile(r"^(?P[a-z][a-z0-9]+)", re.IGNORECASE) + entries = [ + alphanum_re.match(x).group("name").lower() + for x in re.split(r"[ ,]+", settings.APPRISE_DENY_SERVICES) + if alphanum_re.match(x) + ] N_MGR.disable(*entries) @@ -767,8 +736,8 @@ def gen_unique_config_id(): """ # our key to use h = hashlib.sha256() - 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(datetime.now().strftime("%Y%m%d%H%M%S%f").encode("utf-8")) + h.update(settings.SECRET_KEY.encode("utf-8")) return h.hexdigest() @@ -779,28 +748,26 @@ def send_webhook(payload): # Prepare HTTP Headers headers = { - 'User-Agent': 'Apprise-API', - 'Content-Type': 'application/json', + "User-Agent": "Apprise-API", + "Content-Type": "application/json", } 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() except (AttributeError, TypeError): - logger.warning( - 'The Apprise Webhook Result URL is not a valid web based URI') + logger.warning("The Apprise Webhook Result URL is not a valid web based URI") return # Parse our URL results = apprise.URLBase.parse_url(settings.APPRISE_WEBHOOK_URL) if not results: - logger.warning('The Apprise Webhook Result URL is not parseable') + logger.warning("The Apprise Webhook Result URL is not parseable") return - if results['schema'] not in ('http', 'https'): - logger.warning( - 'The Apprise Webhook Result URL is not using the HTTP protocol') + if results["schema"] not in ("http", "https"): + logger.warning("The Apprise Webhook Result URL is not using the HTTP protocol") return # Load our URL @@ -808,8 +775,7 @@ def send_webhook(payload): # Our Query String Dictionary; we use this to track arguments # specified that aren't otherwise part of this class - params = {k: v for k, v in results.get('qsd', {}).items() - if k not in base.template_args} + params = {k: v for k, v in results.get("qsd", {}).items() if k not in base.template_args} try: requests.post( @@ -823,10 +789,8 @@ def send_webhook(payload): ) except requests.RequestException as e: - logger.warning( - 'A Connection error occurred sending the Apprise Webhook ' - 'results to %s.' % base.url(privacy=True)) - logger.debug('Socket Exception: %s' % str(e)) + logger.warning("A Connection error occurred sending the Apprise Webhook results to %s.", base.url(privacy=True)) + logger.debug("Socket Exception: %s", str(e)) return @@ -838,21 +802,21 @@ def healthcheck(lazy=True): # Some status variables we can flip response = { - 'persistent_storage': False, - 'can_write_config': False, - 'can_write_attach': False, - 'details': [], + "persistent_storage": False, + "can_write_config": False, + "can_write_attach": False, + "details": [], } if not (settings.APPRISE_STATEFUL_MODE == AppriseStoreMode.DISABLED or settings.APPRISE_CONFIG_LOCK): # Update our Configuration Check Block - path = os.path.join(ConfigCache.root, '.tmp_hc') + path = os.path.join(ConfigCache.root, ".tmp_hc") if lazy: try: modify_date = datetime.fromtimestamp(os.path.getmtime(path)) delta = (datetime.now() - modify_date).total_seconds() if delta <= 30.00: # 30s - response['can_write_config'] = True + response["can_write_config"] = True except FileNotFoundError: # No worries... continue with below testing @@ -861,34 +825,34 @@ def healthcheck(lazy=True): except OSError: # Permission Issue or something else likely # 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: os.makedirs(ConfigCache.root, exist_ok=True) if touch(path): # Toggle our status - response['can_write_config'] = True + response["can_write_config"] = True else: # 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: # 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: # Test our ability to access write attachments # 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: try: modify_date = datetime.fromtimestamp(os.path.getmtime(path)) delta = (datetime.now() - modify_date).total_seconds() if delta <= 30.00: # 30s - response['can_write_attach'] = True + response["can_write_attach"] = True except FileNotFoundError: # No worries... continue with below testing @@ -896,23 +860,23 @@ def healthcheck(lazy=True): except OSError: # 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 try: os.makedirs(settings.APPRISE_ATTACH_DIR, exist_ok=True) if touch(path): # Toggle our status - response['can_write_attach'] = True + response["can_write_attach"] = True else: # 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: # We can take an early exit - response['details'].append('ATTACH_PERMISSION_ISSUE') + response["details"].append("ATTACH_PERMISSION_ISSUE") if settings.APPRISE_STORAGE_DIR: # @@ -920,13 +884,13 @@ def healthcheck(lazy=True): # store = apprise.PersistentStore( path=settings.APPRISE_STORAGE_DIR, - namespace='tmp_hc', + namespace="tmp_hc", mode=settings.APPRISE_STORAGE_MODE, ) if store.mode != settings.APPRISE_STORAGE_MODE: # Persistent storage not as configured - response['details'].append('STORE_PERMISSION_ISSUE') + response["details"].append("STORE_PERMISSION_ISSUE") elif store.mode != apprise.PersistentStoreMode.MEMORY: # G @@ -936,23 +900,23 @@ def healthcheck(lazy=True): modify_date = datetime.fromtimestamp(os.path.getmtime(path)) delta = (datetime.now() - modify_date).total_seconds() if delta <= 30.00: # 30s - response['persistent_storage'] = True + response["persistent_storage"] = True except OSError: # No worries... continue with below testing pass - if not (store.set('foo', 'bar') and store.flush()): + if not (store.set("foo", "bar") and store.flush()): # No persistent store - response['details'].append('STORE_PERMISSION_ISSUE') + response["details"].append("STORE_PERMISSION_ISSUE") else: # Toggle our status - response['persistent_storage'] = True + response["persistent_storage"] = True # Clear our test - store.clear('foo') + store.clear("foo") - if not response['details']: - response['details'].append('OK') + if not response["details"]: + response["details"].append("OK") return response diff --git a/apprise_api/api/views.py b/apprise_api/api/views.py index 58684d6..f9bf85b 100644 --- a/apprise_api/api/views.py +++ b/apprise_api/api/views.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,74 +21,74 @@ # 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.shortcuts import render -from django.http import HttpResponse -from django.http import JsonResponse -from django.views import View -from django.conf import settings -from django.core.exceptions import RequestDataTooBig -from django.utils.html import escape -from django.utils.decorators import method_decorator -from django.views.decorators.cache import never_cache -from django.views.decorators.gzip import gzip_page -from django.utils.translation import gettext_lazy as _ -from django.core.serializers.json import DjangoJSONEncoder - -from .payload_mapper import remap_fields -from .utils import parse_attachments -from .utils import ConfigCache -from .utils import AppriseStoreMode -from .utils import apply_global_filters -from .utils import send_webhook -from .utils import healthcheck -from .forms import AddByUrlForm -from .forms import AddByConfigForm -from .forms import NotifyForm -from .forms import NotifyByUrlForm -from .forms import CONFIG_FORMATS -from .forms import AUTO_DETECT_CONFIG_KEYWORD - -import logging -import apprise import json +import logging import re +from django.conf import settings +from django.core.exceptions import RequestDataTooBig +from django.core.serializers.json import DjangoJSONEncoder +from django.http import HttpResponse, JsonResponse +from django.shortcuts import render +from django.utils.decorators import method_decorator +from django.utils.html import escape +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.decorators.cache import never_cache +from django.views.decorators.gzip import gzip_page + +import apprise + +from .forms import ( + AUTO_DETECT_CONFIG_KEYWORD, + CONFIG_FORMATS, + AddByConfigForm, + AddByUrlForm, + NotifyByUrlForm, + NotifyForm, +) +from .payload_mapper import remap_fields +from .utils import ( + AppriseStoreMode, + ConfigCache, + apply_global_filters, + healthcheck, + parse_attachments, + send_webhook, +) + # Get an instance of a logger -logger = logging.getLogger('django') +logger = logging.getLogger("django") # Content-Type Parsing # application/x-www-form-urlencoded # multipart/form-data -MIME_IS_FORM = re.compile( - r'(multipart|application)/(x-www-)?form-(data|urlencoded)', re.I) +MIME_IS_FORM = re.compile(r"(multipart|application)/(x-www-)?form-(data|urlencoded)", re.I) # Support JSON formats # text/json # text/x-json # application/json # application/x-json -MIME_IS_JSON = re.compile( - r'(text|application)/(x-)?json', re.I) +MIME_IS_JSON = re.compile(r"(text|application)/(x-)?json", re.I) # Parsing of Accept; the following amounts to Accept All # */* # -ACCEPT_ALL = re.compile(r'^\s*([*]/[*]|)\s*$', re.I) +ACCEPT_ALL = re.compile(r"^\s*([*]/[*]|)\s*$", re.I) # Tags separated by space , &, or + are and'ed together # Tags separated by commas (even commas wrapped in spaces) are "or'ed" together # We start with a regular expression used to clean up provided tags -TAG_VALIDATION_RE = re.compile(r'^\s*[a-z0-9\s| ,_-]+\s*$', re.IGNORECASE) +TAG_VALIDATION_RE = re.compile(r"^\s*[a-z0-9\s| ,_-]+\s*$", re.IGNORECASE) # In order to separate our tags only by comma's or '|' entries found -TAG_DETECT_RE = re.compile( - r'\s*([a-z0-9\s_&+-]+)(?=$|\s*[|,]\s*[a-z0-9\s&+_-|,])', re.I) +TAG_DETECT_RE = re.compile(r"\s*([a-z0-9\s_&+-]+)(?=$|\s*[|,]\s*[a-z0-9\s&+_-|,])", re.I) # Break apart our objects anded together -TAG_AND_DELIM_RE = re.compile(r'[\s&+]+') +TAG_AND_DELIM_RE = re.compile(r"[\s&+]+") -MIME_IS_JSON = re.compile( - r'(text|application)/(x-)?json', re.I) +MIME_IS_JSON = re.compile(r"(text|application)/(x-)?json", re.I) class JSONEncoder(DjangoJSONEncoder): @@ -97,8 +96,9 @@ class JSONEncoder(DjangoJSONEncoder): A wrapper to the DjangoJSONEncoder to support sets() (converting them to lists). """ + def default(self, obj): - if isinstance(obj, set): + if isinstance(obj, set | frozenset): return list(obj) elif isinstance(obj, apprise.locale.LazyTranslation): @@ -107,10 +107,11 @@ class JSONEncoder(DjangoJSONEncoder): return super().default(obj) -class ResponseCode(object): +class ResponseCode: """ These codes are based on those provided by the requests object """ + okay = 200 no_content = 204 bad_request = 400 @@ -127,18 +128,23 @@ class WelcomeView(View): """ A simple welcome/index page """ - template_name = 'welcome.html' + + template_name = "welcome.html" def get(self, request): - default_key = 'KEY' - key = request.GET.get('key', default_key).strip() - return render(request, self.template_name, { - 'secure': request.scheme[-1].lower() == 's', - 'key': key if key else default_key, - }) + default_key = "KEY" + key = request.GET.get("key", default_key).strip() + return render( + request, + self.template_name, + { + "secure": request.scheme[-1].lower() == "s", + "key": key if key else default_key, + }, + ) -@method_decorator((gzip_page, never_cache), name='dispatch') +@method_decorator((gzip_page, never_cache), name="dispatch") class HealthCheckView(View): """ A Django view used to return a simple status/healthcheck @@ -149,41 +155,51 @@ class HealthCheckView(View): Handle a GET request """ # Detect the format our incoming payload - json_payload = \ + json_payload = ( MIME_IS_JSON.match( - request.content_type - if request.content_type - else request.headers.get( - 'content-type', '')) is not None + request.content_type if request.content_type else request.headers.get("content-type", "") + ) + is not None + ) # Detect the format our response should be in - json_response = True if json_payload \ - and ACCEPT_ALL.match(request.headers.get('accept', '')) else \ - MIME_IS_JSON.match(request.headers.get('accept', '')) is not None + json_response = ( + True + if json_payload and ACCEPT_ALL.match(request.headers.get("accept", "")) + else MIME_IS_JSON.match(request.headers.get("accept", "")) is not None + ) # Run our healthcheck; allow ?force which will cause the check to run each time - response = healthcheck(lazy='force' not in request.GET) + response = healthcheck(lazy="force" not in request.GET) # Prepare our response - status = ResponseCode.okay if 'OK' in response['details'] else ResponseCode.expectation_failed + status = ResponseCode.okay if "OK" in response["details"] else ResponseCode.expectation_failed if not json_response: - response = ','.join(response['details']) + response = ",".join(response["details"]) - return HttpResponse(response, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'config_lock': settings.APPRISE_CONFIG_LOCK, - 'attach_lock': settings.APPRISE_ATTACH_SIZE <= 0, - 'status': response, - }, encoder=JSONEncoder, safe=False, status=status) + return ( + HttpResponse(response, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "config_lock": settings.APPRISE_CONFIG_LOCK, + "attach_lock": settings.APPRISE_ATTACH_SIZE <= 0, + "status": response, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) -@method_decorator((gzip_page, never_cache), name='dispatch') +@method_decorator((gzip_page, never_cache), name="dispatch") class DetailsView(View): """ A Django view used to list all supported endpoints """ - template_name = 'details.html' + template_name = "details.html" def get(self, request): """ @@ -191,18 +207,25 @@ class DetailsView(View): """ # Detect the format our response should be in - json_response = \ + json_response = ( MIME_IS_JSON.match( request.content_type if request.content_type - else request.headers.get( - 'accept', request.headers.get( - 'content-type', ''))) is not None + else request.headers.get("accept", request.headers.get("content-type", "")) + ) + is not None + ) # Show All flag # Support 'yes', '1', 'true', 'enable', 'active', and + - show_all = request.GET.get('all', 'no')[0].lower() in ( - 'a', 'y', '1', 't', 'e', '+') + show_all = request.GET.get("all", "no")[0].lower() in ( + "a", + "y", + "1", + "t", + "e", + "+", + ) # Our status status = ResponseCode.okay @@ -219,78 +242,96 @@ class DetailsView(View): details = a_obj.details(show_disabled=show_all) # Sort our result set - details['schemas'] = sorted( - details['schemas'], key=lambda i: str(i['service_name']).upper()) + details["schemas"] = sorted(details["schemas"], key=lambda i: str(i["service_name"]).upper()) # Return our content - return render(request, self.template_name, { - 'show_all': show_all, - 'details': details, - }, status=status) if not json_response else \ - JsonResponse( - details, - encoder=JSONEncoder, - safe=False, - status=status) + return ( + render( + request, + self.template_name, + { + "show_all": show_all, + "details": details, + }, + status=status, + ) + if not json_response + else JsonResponse(details, encoder=JSONEncoder, safe=False, status=status) + ) -@method_decorator(never_cache, name='dispatch') +@method_decorator(never_cache, name="dispatch") class ConfigView(View): """ A Django view used to manage configuration """ - template_name = 'config.html' + + template_name = "config.html" def get(self, request, key): """ Handle a GET request """ - return render(request, self.template_name, { - 'key': key, - 'form_url': AddByUrlForm(), - 'form_cfg': AddByConfigForm(), - 'form_notify': NotifyForm(), - }) + return render( + request, + self.template_name, + { + "key": key, + "form_url": AddByUrlForm(), + "form_cfg": AddByConfigForm(), + "form_notify": NotifyForm(), + }, + ) -@method_decorator(never_cache, name='dispatch') +@method_decorator(never_cache, name="dispatch") class ConfigListView(View): """ A Django view used to list all configuration keys """ - template_name = 'config_list.html' + + template_name = "config_list.html" def get(self, request): """ Handle a GET request """ # Detect the format our response should be in - json_response = \ + json_response = ( MIME_IS_JSON.match( request.content_type if request.content_type - else request.headers.get( - 'accept', request.headers.get( - 'content-type', ''))) is not None + else request.headers.get("accept", request.headers.get("content-type", "")) + ) + is not None + ) if not (settings.APPRISE_ADMIN and settings.APPRISE_STATEFUL_MODE == AppriseStoreMode.SIMPLE): - msg = _('The site has been configured to deny this request') + msg = _("The site has been configured to deny this request") status = ResponseCode.no_access - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) - return render(request, self.template_name, { - 'keys': ConfigCache.keys(), - }) + return render( + request, + self.template_name, + { + "keys": ConfigCache.keys(), + }, + ) -@method_decorator(never_cache, name='dispatch') +@method_decorator(never_cache, name="dispatch") class AddView(View): """ A Django view used to store Apprise configuration @@ -301,32 +342,39 @@ class AddView(View): Handle a POST request """ # Detect the format our incoming payload - json_payload = \ + json_payload = ( MIME_IS_JSON.match( - request.content_type - if request.content_type - else request.headers.get( - 'content-type', '')) is not None + request.content_type if request.content_type else request.headers.get("content-type", "") + ) + is not None + ) # Detect the format our response should be in - json_response = True if json_payload \ - and ACCEPT_ALL.match(request.headers.get('accept', '')) else \ - MIME_IS_JSON.match(request.headers.get('accept', '')) is not None + json_response = ( + True + if json_payload and ACCEPT_ALL.match(request.headers.get("accept", "")) + else MIME_IS_JSON.match(request.headers.get("accept", "")) is not None + ) if settings.APPRISE_CONFIG_LOCK: # General Access Control logger.warning( - 'ADD - %s - Config Lock Active - Request Denied', - request.META['REMOTE_ADDR']) - msg = _('The site has been configured to deny this request') + "ADD - %s - Config Lock Active - Request Denied", + request.META["REMOTE_ADDR"], + ) + msg = _("The site has been configured to deny this request") status = ResponseCode.no_access - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # our content @@ -345,109 +393,156 @@ class AddView(View): # Prepare our default response try: # load our JSON content - content = json.loads(request.body.decode('utf-8')) + content = json.loads(request.body.decode("utf-8")) - except (RequestDataTooBig): + except RequestDataTooBig: # DATA_UPLOAD_MAX_MEMORY_SIZE exceeded it's value; this is usually the case # when there is a very large flie attachment that can't be pulled out of the # payload without exceeding memory limitations (default is 3MB) logger.warning( - 'ADD - %s - JSON Payload Exceeded %dMB; operaton aborted using KEY: %s', - request.META['REMOTE_ADDR'], (settings.DATA_UPLOAD_MAX_MEMORY_SIZE / 1048576), key) + "ADD - %s - JSON Payload Exceeded %dMB; operaton aborted using KEY: %s", + request.META["REMOTE_ADDR"], + (settings.DATA_UPLOAD_MAX_MEMORY_SIZE / 1048576), + key, + ) status = ResponseCode.fields_too_large - msg = _('JSON Payload provided is to large') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("JSON Payload provided is to large") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) except (AttributeError, ValueError): # could not parse JSON response... logger.warning( - 'ADD - %s - Invalid JSON Payload provided using KEY: %s', - request.META['REMOTE_ADDR'], key) + "ADD - %s - Invalid JSON Payload provided using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) status = ResponseCode.bad_request - msg = _('Invalid JSON Payload provided') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("Invalid JSON Payload provided") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) if not content: # No information was posted logger.warning( - 'ADD - %s - Invalid payload structure provided using KEY: %s', - request.META['REMOTE_ADDR'], key) + "ADD - %s - Invalid payload structure provided using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) - msg = _('Invalid payload structure provided') + msg = _("Invalid payload structure provided") status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # Create ourselves an apprise object to work with a_obj = apprise.Apprise() - if 'urls' in content: + if "urls" in content: # Load our content - a_obj.add(content['urls']) + a_obj.add(content["urls"]) if not len(a_obj): # No URLs were loaded logger.warning( - 'ADD - %s - No valid URLs defined using KEY: %s', - request.META['REMOTE_ADDR'], key) + "ADD - %s - No valid URLs defined using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) - msg = _('No valid URLs defined') + msg = _("No valid URLs defined") status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) if not ConfigCache.put( - key, '\r\n'.join([s.url() for s in a_obj]), - apprise.ConfigFormat.TEXT): - + key, + "\r\n".join([s.url() for s in a_obj]), + apprise.ConfigFormat.TEXT.value, + ): logger.warning( - 'ADD - %s - configuration could not be saved using KEY: %s', - request.META['REMOTE_ADDR'], key) - msg = _('The configuration could not be saved') + "ADD - %s - configuration could not be saved using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + msg = _("The configuration could not be saved") status = ResponseCode.internal_server_error - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) - elif 'config' in content: - fmt = content.get('format', '').lower() + elif "config" in content: + fmt = content.get("format", "").lower() if fmt not in [i[0] for i in CONFIG_FORMATS]: # Format must be one supported by apprise logger.warning( - 'ADD - %s - Invalid configuration format specified (%s) using KEY: %s', - request.META['REMOTE_ADDR'], fmt, key) - msg = _('Invalid configuration format specified') + "ADD - %s - Invalid configuration format specified (%s) using KEY: %s", + request.META["REMOTE_ADDR"], + fmt, + key, + ) + msg = _("Invalid configuration format specified") status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # prepare our apprise config object @@ -459,20 +554,26 @@ class AddView(View): fmt = None # Load our configuration - if not ac_obj.add_config(content['config'], format=fmt): + if not ac_obj.add_config(content["config"], format=fmt): # The format could not be detected logger.warning( - 'ADD - %s - The configuration format could not be auto-detected using KEY: %s', - request.META['REMOTE_ADDR'], key) - msg = _('The configuration format could not be auto-detected') + "ADD - %s - The configuration format could not be auto-detected using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + msg = _("The configuration format could not be auto-detected") status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # Add our configuration @@ -482,180 +583,243 @@ class AddView(View): # No specified URL(s) were loaded due to # mis-configuration on the caller's part logger.warning( - 'ADD - %s - No valid URL(s) defined using KEY: %s', - request.META['REMOTE_ADDR'], key) - msg = _('No valid URL(s) defined') + "ADD - %s - No valid URL(s) defined using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + msg = _("No valid URL(s) defined") status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) - if not ConfigCache.put( - key, content['config'], fmt=ac_obj[0].config_format): + if not ConfigCache.put(key, content["config"], fmt=ac_obj[0].config_format.value): # Something went very wrong; return 500 logger.error( - 'ADD - %s - Configuration could not be saved using KEY: %s', - request.META['REMOTE_ADDR'], key) - msg = _('An error occured saving configuration') + "ADD - %s - Configuration could not be saved using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + msg = _("An error occured saving configuration") status = ResponseCode.internal_server_error - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) else: # No configuration specified; we're done - msg = _('No configuration provided') + msg = _("No configuration provided") logger.warning( - 'ADD - %s - No configuration provided using KEY: %s', - request.META['REMOTE_ADDR'], key) + "ADD - %s - No configuration provided using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # If we reach here; we successfully loaded the configuration so we can # go ahead and write it to disk and alert our caller of the success. logger.info( - 'ADD - %s - Configuration saved using KEY: %s', - request.META['REMOTE_ADDR'], key) + "ADD - %s - Configuration saved using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) status = ResponseCode.okay - msg = _('Successfully saved configuration') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - "error": None, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("Successfully saved configuration") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": None, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) -@method_decorator(never_cache, name='dispatch') +@method_decorator(never_cache, name="dispatch") class DelView(View): """ A Django view for removing content associated with a key """ + def post(self, request, key): """ Handle a POST request """ # Detect the format our response should be in - json_response = \ + json_response = ( MIME_IS_JSON.match( request.content_type if request.content_type - else request.headers.get( - 'accept', request.headers.get( - 'content-type', ''))) is not None + else request.headers.get("accept", request.headers.get("content-type", "")) + ) + is not None + ) if settings.APPRISE_CONFIG_LOCK: # General Access Control logger.warning( - 'DEL - %s - Config Lock Active - Request Denied', - request.META['REMOTE_ADDR']) - msg = _('The site has been configured to deny this request') + "DEL - %s - Config Lock Active - Request Denied", + request.META["REMOTE_ADDR"], + ) + msg = _("The site has been configured to deny this request") status = ResponseCode.no_access - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # Clear the key result = ConfigCache.clear(key) if result is None: logger.warning( - 'DEL - %s - No configuration associated using KEY: %s', - request.META['REMOTE_ADDR'], key) - msg = _('There was no configuration to remove') + "DEL - %s - No configuration associated using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + msg = _("There was no configuration to remove") status = ResponseCode.no_content - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) elif result is False: # There was a failure at the os level logger.error( - 'DEL - %s - Configuration could not be removed associated using KEY: %s', - request.META['REMOTE_ADDR'], key) + "DEL - %s - Configuration could not be removed associated using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) - msg = _('The configuration could not be removed') + msg = _("The configuration could not be removed") status = ResponseCode.internal_server_error - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) + + # Removed content + logger.info( + "DEL - %s - Removed configuration associated using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + + status = ResponseCode.okay + msg = _("Successfully removed configuration") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": None, }, encoder=JSONEncoder, safe=False, status=status, ) - - # Removed content - logger.info( - 'DEL - %s - Removed configuration associated using KEY: %s', - request.META['REMOTE_ADDR'], key) - - status = ResponseCode.okay - msg = _('Successfully removed configuration') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - "error": None, - }, encoder=JSONEncoder, safe=False, status=status) + ) -@method_decorator((gzip_page, never_cache), name='dispatch') +@method_decorator((gzip_page, never_cache), name="dispatch") class GetView(View): """ A Django view used to retrieve previously stored Apprise configuration """ + def post(self, request, key): """ Handle a POST request """ # Detect the format our response should be in - json_response = \ + json_response = ( MIME_IS_JSON.match( request.content_type if request.content_type - else request.headers.get( - 'accept', request.headers.get( - 'content-type', ''))) is not None + else request.headers.get("accept", request.headers.get("content-type", "")) + ) + is not None + ) if settings.APPRISE_CONFIG_LOCK: # General Access Control logger.warning( - 'VIEW - %s - Config Lock Active - Request Denied', - request.META['REMOTE_ADDR']) + "VIEW - %s - Config Lock Active - Request Denied", + request.META["REMOTE_ADDR"], + ) - msg = _('The site has been configured to deny this request') + msg = _("The site has been configured to deny this request") status = ResponseCode.no_access - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse( - {'error': msg}, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + {"error": msg}, encoder=JSONEncoder, safe=False, - status=status) + status=status, + ) + ) config, format = ConfigCache.get(key) if config is None: @@ -668,81 +832,101 @@ class GetView(View): if format is not None: # no content to return logger.warning( - 'VIEW - %s - No configuration associated using KEY: %s', - request.META['REMOTE_ADDR'], key) - msg = _('There was no configuration found') + "VIEW - %s - No configuration associated using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + msg = _("There was no configuration found") status = ResponseCode.no_content - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse( - {'error': msg}, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + {"error": msg}, encoder=JSONEncoder, safe=False, - status=status) + status=status, + ) + ) # Something went very wrong; return 500 logger.error( - 'VIEW - %s - Configuration could not be accessed associated using KEY: %s', - request.META['REMOTE_ADDR'], key) - msg = _('An error occured accessing configuration') + "VIEW - %s - Configuration could not be accessed associated using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + msg = _("An error occured accessing configuration") status = ResponseCode.internal_server_error - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # Our configuration was retrieved; now our response varies on whether # we are a YAML configuration or a TEXT based one. This allows us to # be compatible with those using the AppriseConfig() library or the # reference to it through the --config (-c) option in the CLI. - content_type = 'text/yaml; charset=utf-8' \ - if format == apprise.ConfigFormat.YAML \ - else 'text/plain; charset=utf-8' + content_type = ( + "text/yaml; charset=utf-8" if format == apprise.ConfigFormat.YAML.value else "text/plain; charset=utf-8" + ) # Return our retrieved content logger.info( - 'VIEW - %s - Retrieved configuration associated using KEY: %s', - request.META['REMOTE_ADDR'], key) - return HttpResponse( - config, - content_type=content_type, - status=ResponseCode.okay, - ) if not json_response else JsonResponse({ - 'format': format, - 'config': config}, - encoder=JSONEncoder, - safe=False, - status=ResponseCode.okay, + "VIEW - %s - Retrieved configuration associated using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + return ( + HttpResponse( + config, + content_type=content_type, + status=ResponseCode.okay, + ) + if not json_response + else JsonResponse( + {"format": format, "config": config}, + encoder=JSONEncoder, + safe=False, + status=ResponseCode.okay, + ) ) -@method_decorator((gzip_page, never_cache), name='dispatch') +@method_decorator((gzip_page, never_cache), name="dispatch") class NotifyView(View): """ A Django view for sending a notification in a stateful manner """ + def post(self, request, key): """ Handle a POST request """ # Detect the format our incoming payload - json_payload = \ + json_payload = ( MIME_IS_JSON.match( - request.content_type - if request.content_type - else request.headers.get( - 'content-type', '')) is not None + request.content_type if request.content_type else request.headers.get("content-type", "") + ) + is not None + ) # Detect the format our response should be in - json_response = True if json_payload \ - and ACCEPT_ALL.match(request.headers.get('accept', '')) else \ - MIME_IS_JSON.match(request.headers.get('accept', '')) is not None + json_response = ( + True + if json_payload and ACCEPT_ALL.match(request.headers.get("accept", "")) + else MIME_IS_JSON.match(request.headers.get("accept", "")) is not None + ) # rules - rules = {k[1:]: v for k, v in request.GET.items() if k[0] == ':'} + rules = {k[1:]: v for k, v in request.GET.items() if k[0] == ":"} # our content content = {} @@ -764,105 +948,142 @@ class NotifyView(View): # Prepare our default response try: # load our JSON content - content = json.loads(request.body.decode('utf-8')) + content = json.loads(request.body.decode("utf-8")) # Apply content rules if rules: remap_fields(rules, content) - except (RequestDataTooBig): + except RequestDataTooBig: # DATA_UPLOAD_MAX_MEMORY_SIZE exceeded it's value; this is usually the case # when there is a very large flie attachment that can't be pulled out of the # payload without exceeding memory limitations (default is 3MB) logger.warning( - 'NOTIFY - %s - JSON Payload Exceeded %dMB using KEY: %s', - request.META['REMOTE_ADDR'], (settings.DATA_UPLOAD_MAX_MEMORY_SIZE / 1048576), key) + "NOTIFY - %s - JSON Payload Exceeded %dMB using KEY: %s", + request.META["REMOTE_ADDR"], + (settings.DATA_UPLOAD_MAX_MEMORY_SIZE / 1048576), + key, + ) status = ResponseCode.fields_too_large - msg = _('JSON Payload provided is to large') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("JSON Payload provided is to large") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) except (AttributeError, ValueError): # could not parse JSON response... logger.warning( - 'NOTIFY - %s - Invalid JSON Payload provided using KEY: %s', - request.META['REMOTE_ADDR'], key) + "NOTIFY - %s - Invalid JSON Payload provided using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) status = ResponseCode.bad_request - msg = _('Invalid JSON Payload provided') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("Invalid JSON Payload provided") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) if not content: # We could not handle the Content-Type logger.warning( - 'NOTIFY - %s - Invalid FORM Payload provided using KEY: %s', - request.META['REMOTE_ADDR'], key) + "NOTIFY - %s - Invalid FORM Payload provided using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) - msg = _('Bad FORM Payload provided') + msg = _("Bad FORM Payload provided") status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # Handle Attachments attach = None - if not content.get('attachment'): - if 'attachment' in request.POST: + if not content.get("attachment"): + if "attachment" in request.POST: # Acquire attachments to work with them - content['attachment'] = \ - [a for a in request.POST.getlist('attachment') if isinstance(a, str) and a.strip()] + content["attachment"] = [ + a for a in request.POST.getlist("attachment") if isinstance(a, str) and a.strip() + ] - elif 'attach' in request.POST: + elif "attach" in request.POST: # Acquire kw (alias) attach to work with them - content['attachment'] = \ - [a for a in request.POST.getlist('attach') if isinstance(a, str) and a.strip()] + content["attachment"] = [a for a in request.POST.getlist("attach") if isinstance(a, str) and a.strip()] - elif content.get('attach'): + elif content.get("attach"): # Acquire kw (alias) attach from payload to work with - content['attachment'] = content['attach'] - del content['attach'] + content["attachment"] = content["attach"] + del content["attach"] - if 'attachment' in content or request.FILES: + if "attachment" in content or request.FILES: try: - attach = parse_attachments( - content.get('attachment'), request.FILES) + attach = parse_attachments(content.get("attachment"), request.FILES) except (TypeError, ValueError) as e: # Invalid entry found in list logger.warning( - 'NOTIFY - %s - Bad attachment using KEY: %s - %s', - request.META['REMOTE_ADDR'], key, str(e)) + "NOTIFY - %s - Bad attachment using KEY: %s - %s", + request.META["REMOTE_ADDR"], + key, + str(e), + ) status = ResponseCode.bad_request - msg = _('Bad Attachment') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("Bad Attachment") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) # # Allow 'tag' value to be specified as part of the URL parameters # if not found otherwise defined. # - tag = content.get('tag') if content.get('tag') else content.get('tags') + tag = content.get("tag") if content.get("tag") else content.get("tags") if not tag: # Allow GET parameter over-rides - if 'tag' in request.GET: - tag = request.GET['tag'] + if "tag" in request.GET: + tag = request.GET["tag"] - elif 'tags' in request.GET: - tag = request.GET['tags'] + elif "tags" in request.GET: + tag = request.GET["tags"] # Validation - Tag Logic: # "TagA" : TagA @@ -873,27 +1094,34 @@ class NotifyView(View): # [('TagA', 'TagC'), 'TagB'] : (TagA AND TagC) OR TagB # [('TagB', 'TagC')] : TagB AND TagC if tag: - if isinstance(tag, (list, set, tuple)): + if isinstance(tag, list | set | tuple): # Assign our tags as they were provided - content['tag'] = tag + content["tag"] = tag elif isinstance(tag, str): if not TAG_VALIDATION_RE.match(tag): # Invalid entry found in list logger.warning( - 'NOTIFY - %s - Ignored invalid tag specified ' - '(type %s): %s using KEY: %s', request.META['REMOTE_ADDR'], - str(type(tag)), str(tag)[:12], key) + "NOTIFY - %s - Ignored invalid tag specified (type %s): %s using KEY: %s", + request.META["REMOTE_ADDR"], + str(type(tag)), + str(tag)[:12], + key, + ) - msg = _('Unsupported characters found in tag definition') + msg = _("Unsupported characters found in tag definition") status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # If we get here, our specified tag was valid @@ -911,81 +1139,100 @@ class NotifyView(View): tags.append(tag) # Assign our tags - content['tag'] = tags + content["tag"] = tags else: # Could be int, float or some other unsupported type logger.warning( - 'NOTIFY - %s - Ignored invalid tag specified (type %s): ' - '%s using KEY: %s', request.META['REMOTE_ADDR'], - str(type(tag)), str(tag)[:12], key) + "NOTIFY - %s - Ignored invalid tag specified (type %s): %s using KEY: %s", + request.META["REMOTE_ADDR"], + str(type(tag)), + str(tag)[:12], + key, + ) - msg = _('Unsupported characters found in tag definition') + msg = _("Unsupported characters found in tag definition") status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # # Allow 'format' value to be specified as part of the URL # parameters if not found otherwise defined. # - if not content.get('format') and 'format' in request.GET: - content['format'] = request.GET['format'] + if not content.get("format") and "format" in request.GET: + content["format"] = request.GET["format"] # # Allow 'type' value to be specified as part of the URL parameters # if not found otherwise defined. # - if not content.get('type') and 'type' in request.GET: - content['type'] = request.GET['type'] + if not content.get("type") and "type" in request.GET: + content["type"] = request.GET["type"] # # Allow 'title' value to be specified as part of the URL parameters # if not found otherwise defined. # - if not content.get('title') and 'title' in request.GET: - content['title'] = request.GET['title'] + if not content.get("title") and "title" in request.GET: + content["title"] = request.GET["title"] # Some basic error checking - if not content.get('body') and not attach or \ - content.get('type', apprise.NotifyType.INFO) \ - not in apprise.NOTIFY_TYPES: - + if (not content.get("body") and not attach) or content.get( + "type", apprise.NotifyType.INFO.value + ) not in apprise.NOTIFY_TYPES: logger.warning( - 'NOTIFY - %s - Payload lacks minimum requirements using KEY: %s', - request.META['REMOTE_ADDR'], key) + "NOTIFY - %s - Payload lacks minimum requirements using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) status = ResponseCode.bad_request - msg = _('Payload lacks minimum requirements') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=ResponseCode.bad_request, + msg = _("Payload lacks minimum requirements") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=ResponseCode.bad_request, + ) ) # Acquire our body format (if identified) - body_format = content.get('format', apprise.NotifyFormat.TEXT) + body_format = content.get("format", apprise.NotifyFormat.TEXT.value) if body_format and body_format not in apprise.NOTIFY_FORMATS: logger.warning( - 'NOTIFY - %s - Format parameter contains an unsupported ' - 'value (%s) using KEY: %s', request.META['REMOTE_ADDR'], str(body_format), key) + "NOTIFY - %s - Format parameter contains an unsupported value (%s) using KEY: %s", + request.META["REMOTE_ADDR"], + str(body_format), + key, + ) - msg = _('An invalid body input format was specified') + msg = _("An invalid body input format was specified") status = ResponseCode.bad_request - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) ) # If we get here, we have enough information to generate a notification @@ -1001,54 +1248,66 @@ class NotifyView(View): if format is not None: # no content to return logger.debug( - 'NOTIFY - %s - Empty configuration found using KEY: %s', - request.META['REMOTE_ADDR'], key) + "NOTIFY - %s - Empty configuration found using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) - msg = _('There was no configuration found') + msg = _("There was no configuration found") status = ResponseCode.no_content - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) + + logger.error( + "NOTIFY - %s - I/O error accessing configuration using KEY: %s", + request.META["REMOTE_ADDR"], + key, + ) + + # Something went very wrong; return 500 + msg = _("An error occured accessing configuration") + status = ResponseCode.internal_server_error + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, }, encoder=JSONEncoder, safe=False, status=status, ) - - logger.error( - 'NOTIFY - %s - I/O error accessing configuration ' - 'using KEY: %s', request.META['REMOTE_ADDR'], key) - - # Something went very wrong; return 500 - msg = _('An error occured accessing configuration') - status = ResponseCode.internal_server_error - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, - encoder=JSONEncoder, - safe=False, - status=status, ) # Prepare our keyword arguments (to be passed into an AppriseAsset object) kwargs = { # Load our dynamic plugin path - 'plugin_paths': settings.APPRISE_PLUGIN_PATHS, + "plugin_paths": settings.APPRISE_PLUGIN_PATHS, # Load our persistent storage path - 'storage_path': settings.APPRISE_STORAGE_DIR, + "storage_path": settings.APPRISE_STORAGE_DIR, # Our storage URL ID Length - 'storage_idlen': settings.APPRISE_STORAGE_UID_LENGTH, + "storage_idlen": settings.APPRISE_STORAGE_UID_LENGTH, # Define if we flush to disk as soon as possible or not when required - 'storage_mode': settings.APPRISE_STORAGE_MODE, + "storage_mode": settings.APPRISE_STORAGE_MODE, } if body_format: # Store our defined body format - kwargs['body_format'] = body_format + kwargs["body_format"] = body_format # Acquire our recursion count (if defined) - recursion = request.headers.get('X-Apprise-Recursion-Count', 0) + recursion = request.headers.get("X-Apprise-Recursion-Count", 0) try: recursion = int(recursion) @@ -1058,36 +1317,56 @@ class NotifyView(View): if recursion > settings.APPRISE_RECURSION_MAX: logger.warning( - 'NOTIFY - %s - Recursion limit reached (%d > %d)', - request.META['REMOTE_ADDR'], recursion, - settings.APPRISE_RECURSION_MAX) + "NOTIFY - %s - Recursion limit reached (%d > %d)", + request.META["REMOTE_ADDR"], + recursion, + settings.APPRISE_RECURSION_MAX, + ) status = ResponseCode.method_not_accepted - msg = _('The recursion limit has been reached') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("The recursion limit has been reached") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) # Store our recursion value for our AppriseAsset() initialization - kwargs['_recursion'] = recursion + kwargs["_recursion"] = recursion except (TypeError, ValueError): logger.warning( - 'NOTIFY - %s - Invalid recursion value (%s) provided', - request.META['REMOTE_ADDR'], str(recursion)) + "NOTIFY - %s - Invalid recursion value (%s) provided", + request.META["REMOTE_ADDR"], + str(recursion), + ) status = ResponseCode.bad_request - msg = _('An invalid recursion value was specified') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("An invalid recursion value was specified") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) # Acquire our unique identifier (if defined) - uid = request.headers.get('X-Apprise-ID', '').strip() + uid = request.headers.get("X-Apprise-ID", "").strip() if uid: - kwargs['_uid'] = uid + kwargs["_uid"] = uid # # Apply Any Global Filters (if identified) @@ -1115,71 +1394,84 @@ class NotifyView(View): # The HTML response type has a bit of overhead where as it's not # the case with text/plain if not json_response: - content_type = \ - 'text/html' if re.search(r'text\/(\*|html)', - request.headers.get( - 'Accept', request.content_type - if request.content_type - else request.headers.get('Content-Type', '')), - re.IGNORECASE) \ - else 'text/plain' + content_type = ( + "text/html" + if re.search( + r"text\/(\*|html)", + request.headers.get( + "Accept", + (request.content_type if request.content_type else request.headers.get("Content-Type", "")), + ), + re.IGNORECASE, + ) + else "text/plain" + ) else: - content_type = 'application/json' + content_type = "application/json" # Acquire our log level from headers if defined, otherwise use # the global one set in the settings level = request.headers.get( - 'X-Apprise-Log-Level', - settings.LOGGING['loggers']['apprise']['level']).upper() + "X-Apprise-Log-Level", + settings.LOGGING["loggers"]["apprise"]["level"], + ).upper() if level not in ( - 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'TRACE'): - level = settings.LOGGING['loggers']['apprise']['level'].upper() + "CRITICAL", + "ERROR", + "WARNING", + "INFO", + "DEBUG", + "TRACE", + ): + level = settings.LOGGING["loggers"]["apprise"]["level"].upper() # Convert level to it's integer value - if level == 'CRITICAL': + if level == "CRITICAL": level = logging.CRITICAL - elif level == 'ERROR': + elif level == "ERROR": level = logging.ERROR - elif level == 'WARNING': + elif level == "WARNING": level = logging.WARNING - elif level == 'INFO': + elif level == "INFO": level = logging.INFO - elif level == 'DEBUG': + elif level == "DEBUG": level = logging.DEBUG - elif level == 'TRACE': + elif level == "TRACE": level = logging.DEBUG - 1 # Initialize our response object response = None - esc = '' + esc = "" if json_response: fmt = f'["%(levelname)s","%(asctime)s","{esc}%(message)s{esc}"]' else: # Format is only updated if the content_type is html - fmt = '
  • ' \ - '
    %(asctime)s
    ' \ - '
    %(levelname)s
    ' \ - f'
    {esc}%(message)s{esc}
  • ' \ - if content_type == 'text/html' else \ - settings.LOGGING['formatters']['standard']['format'] + fmt = ( + '
  • ' + '
    %(asctime)s
    ' + '
    %(levelname)s
    ' + f'
    {esc}%(message)s{esc}
  • ' + if content_type == "text/html" + else settings.LOGGING["formatters"]["standard"]["format"] + ) # Now specify our format (and over-ride the default): with apprise.LogCapture(level=level, fmt=fmt) as logs: # Perform our notification at this point result = a_obj.notify( - content.get('body'), - title=content.get('title', ''), - notify_type=content.get('type', apprise.NotifyType.INFO), - tag=content.get('tag'), + content.get("body"), + title=content.get("title", ""), + notify_type=content.get("type", apprise.NotifyType.INFO.value), + tag=content.get("tag"), attach=attach, ) @@ -1187,34 +1479,36 @@ class NotifyView(View): esc = re.escape(esc) entries = re.findall( r'\r*\n?(?P\["[^"]+","[^"]+",)"{}(?P.+?)' - r'{}"(?P\]\r*\n?$)(?=$|\r*\n?\["[^"]+","[^"]+","{})'.format( - esc, esc, esc), logs.getvalue(), re.DOTALL | re.MULTILINE) + r'{}"(?P\]\r*\n?$)(?=$|\r*\n?\["[^"]+","[^"]+","{})'.format(esc, esc, esc), + logs.getvalue(), + re.DOTALL | re.MULTILINE, + ) # Prepare ourselves a JSON Response - response = json.loads('[{}]'.format( - ','.join([e[0] + json.dumps(e[1].rstrip()) + e[2] for e in entries]))) + response = json.loads("[{}]".format(",".join([e[0] + json.dumps(e[1].rstrip()) + e[2] for e in entries]))) - elif content_type == 'text/html': + elif content_type == "text/html": # Iterate over our entries so that we can prepare to escape # things to be presented as HTML esc = re.escape(esc) entries = re.findall( r'\r*\n?(?P
  • \r*\n?$)(?=$|\r*\n?
  • {}
'.format( - ''.join([e[0] + escape(e[1]) + e[2] for e in entries])) + response = '
    {}
'.format("".join([e[0] + escape(e[1]) + e[2] for e in entries])) else: # content_type == 'text/plain' response = logs.getvalue() if settings.APPRISE_WEBHOOK_URL: webhook_payload = { - 'source': request.META['REMOTE_ADDR'], - 'status': 0 if result else 1, - 'output': response, + "source": request.META["REMOTE_ADDR"], + "status": 0 if result else 1, + "output": response, } # Send our webhook (pass or fail) @@ -1223,60 +1517,83 @@ class NotifyView(View): if not result: # If at least one notification couldn't be sent; change up # the response to a 424 error code - msg = _('One or more notification could not be sent') + msg = _("One or more notification could not be sent") status = ResponseCode.failed_dependency logger.warning( - 'NOTIFY - %s - One or more notifications not ' - 'sent%s using KEY: %s', request.META['REMOTE_ADDR'], - '' if not tag else f' (Tags: {tag})', key) - return HttpResponse(response if response else msg, status=status, content_type=content_type) \ - if not json_response else JsonResponse({ - 'error': msg, - 'details': response, + "NOTIFY - %s - One or more notifications not sent%s using KEY: %s", + request.META["REMOTE_ADDR"], + "" if not tag else f" (Tags: {tag})", + key, + ) + return ( + HttpResponse( + response if response else msg, + status=status, + content_type=content_type, + ) + if not json_response + else JsonResponse( + { + "error": msg, + "details": response, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) + + logger.info( + "NOTIFY - %s - Delivered Notification(s) - %sKEY: %s", + request.META["REMOTE_ADDR"], + "" if not tag else f"Tags: {tag}, ", + key, + ) + + # Return our success message + status = ResponseCode.okay + return ( + HttpResponse(response, status=status, content_type=content_type) + if not json_response + else JsonResponse( + { + "error": None, + "details": response, }, encoder=JSONEncoder, safe=False, status=status, ) - - logger.info( - 'NOTIFY - %s - Delivered Notification(s) - %sKEY: %s', - request.META['REMOTE_ADDR'], - '' if not tag else f'Tags: {tag}, ', key) - - # Return our success message - status = ResponseCode.okay - return HttpResponse(response, status=status, content_type=content_type) \ - if not json_response else JsonResponse({ - 'error': None, - 'details': response, - }, encoder=JSONEncoder, safe=False, status=status) + ) -@method_decorator((gzip_page, never_cache), name='dispatch') +@method_decorator((gzip_page, never_cache), name="dispatch") class StatelessNotifyView(View): """ A Django view for sending a stateless notification """ + def post(self, request): """ Handle a POST request """ # Detect the format our incoming payload - json_payload = \ + json_payload = ( MIME_IS_JSON.match( - request.content_type - if request.content_type - else request.headers.get( - 'content-type', '')) is not None + request.content_type if request.content_type else request.headers.get("content-type", "") + ) + is not None + ) # Detect the format our response should be in - json_response = True if json_payload \ - and ACCEPT_ALL.match(request.headers.get('accept', '')) else \ - MIME_IS_JSON.match(request.headers.get('accept', '')) is not None + json_response = ( + True + if json_payload and ACCEPT_ALL.match(request.headers.get("accept", "")) + else MIME_IS_JSON.match(request.headers.get("accept", "")) is not None + ) # rules - rules = {k[1:]: v for k, v in request.GET.items() if k[0] == ':'} + rules = {k[1:]: v for k, v in request.GET.items() if k[0] == ":"} # our content content = {} @@ -1298,131 +1615,177 @@ class StatelessNotifyView(View): # Prepare our default response try: # load our JSON content - content = json.loads(request.body.decode('utf-8')) + content = json.loads(request.body.decode("utf-8")) # Apply content rules if rules: remap_fields(rules, content, form=NotifyByUrlForm()) - except (RequestDataTooBig): + except RequestDataTooBig: # DATA_UPLOAD_MAX_MEMORY_SIZE exceeded it's value; this is usually the case # when there is a very large flie attachment that can't be pulled out of the # payload without exceeding memory limitations (default is 3MB) logger.warning( - 'NOTIFY - %s - JSON Payload Exceeded %dMB; operaton aborted', - request.META['REMOTE_ADDR'], (settings.DATA_UPLOAD_MAX_MEMORY_SIZE / 1048576)) + "NOTIFY - %s - JSON Payload Exceeded %dMB; operaton aborted", + request.META["REMOTE_ADDR"], + (settings.DATA_UPLOAD_MAX_MEMORY_SIZE / 1048576), + ) status = ResponseCode.fields_too_large - msg = _('JSON Payload provided is to large') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("JSON Payload provided is to large") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) except (AttributeError, ValueError): # could not parse JSON response... logger.warning( - 'NOTIFY - %s - Invalid JSON Payload provided', - request.META['REMOTE_ADDR']) + "NOTIFY - %s - Invalid JSON Payload provided", + request.META["REMOTE_ADDR"], + ) status = ResponseCode.bad_request - msg = _('Invalid JSON Payload provided') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("Invalid JSON Payload provided") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) if not content: # We could not handle the Content-Type logger.warning( - 'NOTIFY - %s - Invalid FORM Payload provided', - request.META['REMOTE_ADDR']) + "NOTIFY - %s - Invalid FORM Payload provided", + request.META["REMOTE_ADDR"], + ) status = ResponseCode.bad_request - msg = _('Bad FORM Payload provided') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("Bad FORM Payload provided") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) - if not content.get('urls') and settings.APPRISE_STATELESS_URLS: + if not content.get("urls") and settings.APPRISE_STATELESS_URLS: # fallback to settings.APPRISE_STATELESS_URLS if no urls were # defined - content['urls'] = settings.APPRISE_STATELESS_URLS + content["urls"] = settings.APPRISE_STATELESS_URLS # # Allow 'format' value to be specified as part of the URL # parameters if not found otherwise defined. # - if not content.get('format') and 'format' in request.GET: - content['format'] = request.GET['format'] + if not content.get("format") and "format" in request.GET: + content["format"] = request.GET["format"] # # Allow 'type' value to be specified as part of the URL parameters # if not found otherwise defined. # - if not content.get('type') and 'type' in request.GET: - content['type'] = request.GET['type'] + if not content.get("type") and "type" in request.GET: + content["type"] = request.GET["type"] # # Allow 'title' value to be specified as part of the URL parameters # if not found otherwise defined. # - if not content.get('title') and 'title' in request.GET: - content['title'] = request.GET['title'] + if not content.get("title") and "title" in request.GET: + content["title"] = request.GET["title"] # Some basic error checking - if not content.get('body') or \ - content.get('type', apprise.NotifyType.INFO) \ - not in apprise.NOTIFY_TYPES: - + if not content.get("body") or content.get("type", apprise.NotifyType.INFO.value) not in apprise.NOTIFY_TYPES: logger.warning( - 'NOTIFY - %s - Payload lacks minimum requirements', - request.META['REMOTE_ADDR']) + "NOTIFY - %s - Payload lacks minimum requirements", + request.META["REMOTE_ADDR"], + ) status = ResponseCode.bad_request - msg = _('Payload lacks minimum requirements') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("Payload lacks minimum requirements") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) # Acquire our body format (if identified) - body_format = content.get('format', apprise.NotifyFormat.TEXT) + body_format = content.get("format", apprise.NotifyFormat.TEXT.value) if body_format and body_format not in apprise.NOTIFY_FORMATS: logger.warning( - 'NOTIFY - %s - Format parameter contains an unsupported ' - 'value (%s)', request.META['REMOTE_ADDR'], str(body_format)) + "NOTIFY - %s - Format parameter contains an unsupported value (%s)", + request.META["REMOTE_ADDR"], + str(body_format), + ) status = ResponseCode.bad_request - msg = _('An invalid body input format was specified') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("An invalid body input format was specified") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) # Prepare our keyword arguments (to be passed into an AppriseAsset object) kwargs = { # Load our dynamic plugin path - 'plugin_paths': settings.APPRISE_PLUGIN_PATHS, + "plugin_paths": settings.APPRISE_PLUGIN_PATHS, } if settings.APPRISE_STATELESS_STORAGE: # Persistent Storage is allowed with Stateless queries - kwargs.update({ - # Load our persistent storage path - 'storage_path': settings.APPRISE_STORAGE_DIR, - # Our storage URL ID Length - 'storage_idlen': settings.APPRISE_STORAGE_UID_LENGTH, - # Define if we flush to disk as soon as possible or not when required - 'storage_mode': settings.APPRISE_STORAGE_MODE, - }) + kwargs.update( + { + # Load our persistent storage path + "storage_path": settings.APPRISE_STORAGE_DIR, + # Our storage URL ID Length + "storage_idlen": settings.APPRISE_STORAGE_UID_LENGTH, + # Define if we flush to disk as soon as possible or not when required + "storage_mode": settings.APPRISE_STORAGE_MODE, + } + ) if body_format: # Store our defined body format - kwargs['body_format'] = body_format + kwargs["body_format"] = body_format # Acquire our recursion count (if defined) - recursion = request.headers.get('X-Apprise-Recursion-Count', 0) + recursion = request.headers.get("X-Apprise-Recursion-Count", 0) try: recursion = int(recursion) @@ -1432,36 +1795,56 @@ class StatelessNotifyView(View): if recursion > settings.APPRISE_RECURSION_MAX: logger.warning( - 'NOTIFY - %s - Recursion limit reached (%d > %d)', - request.META['REMOTE_ADDR'], recursion, - settings.APPRISE_RECURSION_MAX) + "NOTIFY - %s - Recursion limit reached (%d > %d)", + request.META["REMOTE_ADDR"], + recursion, + settings.APPRISE_RECURSION_MAX, + ) status = ResponseCode.method_not_accepted - msg = _('The recursion limit has been reached') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("The recursion limit has been reached") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) # Store our recursion value for our AppriseAsset() initialization - kwargs['_recursion'] = recursion + kwargs["_recursion"] = recursion except (TypeError, ValueError): logger.warning( - 'NOTIFY - %s - Invalid recursion value (%s) provided', - request.META['REMOTE_ADDR'], str(recursion)) + "NOTIFY - %s - Invalid recursion value (%s) provided", + request.META["REMOTE_ADDR"], + str(recursion), + ) status = ResponseCode.bad_request - msg = _('An invalid recursion value was specified') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("An invalid recursion value was specified") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) # Acquire our unique identifier (if defined) - uid = request.headers.get('X-Apprise-ID', '').strip() + uid = request.headers.get("X-Apprise-ID", "").strip() if uid: - kwargs['_uid'] = uid + kwargs["_uid"] = uid # # Apply Any Global Filters (if identified) @@ -1475,52 +1858,70 @@ class StatelessNotifyView(View): a_obj = apprise.Apprise(asset=asset) # Add URLs - a_obj.add(content.get('urls')) + a_obj.add(content.get("urls")) if not len(a_obj): logger.warning( - 'NOTIFY - %s - No valid URLs provided', - request.META['REMOTE_ADDR']) + "NOTIFY - %s - No valid URLs provided", + request.META["REMOTE_ADDR"], + ) status = ResponseCode.no_content - msg = _('There was no valid URLs provided to notify') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("There was no valid URLs provided to notify") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) # Handle Attachments attach = None - if not content.get('attachment'): - if 'attachment' in request.POST: + if not content.get("attachment"): + if "attachment" in request.POST: # Acquire attachments to work with them - content['attachment'] = request.POST.getlist('attachment') + content["attachment"] = request.POST.getlist("attachment") - elif 'attach' in request.POST: + elif "attach" in request.POST: # Acquire kw (alias) attach to work with them - content['attachment'] = request.POST.getlist('attach') + content["attachment"] = request.POST.getlist("attach") - elif content.get('attach'): + elif content.get("attach"): # Acquire kw (alias) attach from payload to work with - content['attachment'] = content['attach'] - del content['attach'] + content["attachment"] = content["attach"] + del content["attach"] - if 'attachment' in content or request.FILES: + if "attachment" in content or request.FILES: try: - attach = parse_attachments( - content.get('attachment'), request.FILES) + attach = parse_attachments(content.get("attachment"), request.FILES) except (TypeError, ValueError) as e: # Invalid entry found in list logger.warning( - 'NOTIFY - %s - Bad attachment: %s', - request.META['REMOTE_ADDR'], str(e)) + "NOTIFY - %s - Bad attachment: %s", + request.META["REMOTE_ADDR"], + str(e), + ) status = ResponseCode.bad_request - msg = _('Bad Attachment') - return HttpResponse(msg, status=status, content_type='text/plain') \ - if not json_response else JsonResponse({ - 'error': msg, - }, encoder=JSONEncoder, safe=False, status=status) + msg = _("Bad Attachment") + return ( + HttpResponse(msg, status=status, content_type="text/plain") + if not json_response + else JsonResponse( + { + "error": msg, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) # Our return content type can be controlled by the Accept keyword # If it includes /* or /html somewhere then we return html, otherwise @@ -1528,70 +1929,83 @@ class StatelessNotifyView(View): # The HTML response type has a bit of overhead where as it's not # the case with text/plain if not json_response: - content_type = \ - 'text/html' if re.search(r'text\/(\*|html)', - request.headers.get( - 'Accept', request.content_type - if request.content_type - else request.headers.get('Content-Type', '')), - re.IGNORECASE) \ - else 'text/plain' + content_type = ( + "text/html" + if re.search( + r"text\/(\*|html)", + request.headers.get( + "Accept", + (request.content_type if request.content_type else request.headers.get("Content-Type", "")), + ), + re.IGNORECASE, + ) + else "text/plain" + ) else: - content_type = 'application/json' + content_type = "application/json" # Acquire our log level from headers if defined, otherwise use # the global one set in the settings level = request.headers.get( - 'X-Apprise-Log-Level', - settings.LOGGING['loggers']['apprise']['level']).upper() + "X-Apprise-Log-Level", + settings.LOGGING["loggers"]["apprise"]["level"], + ).upper() if level not in ( - 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'TRACE'): - level = settings.LOGGING['loggers']['apprise']['level'].upper() + "CRITICAL", + "ERROR", + "WARNING", + "INFO", + "DEBUG", + "TRACE", + ): + level = settings.LOGGING["loggers"]["apprise"]["level"].upper() # Convert level to it's integer value - if level == 'CRITICAL': + if level == "CRITICAL": level = logging.CRITICAL - elif level == 'ERROR': + elif level == "ERROR": level = logging.ERROR - elif level == 'WARNING': + elif level == "WARNING": level = logging.WARNING - elif level == 'INFO': + elif level == "INFO": level = logging.INFO - elif level == 'DEBUG': + elif level == "DEBUG": level = logging.DEBUG - elif level == 'TRACE': + elif level == "TRACE": level = logging.DEBUG - 1 # Initialize our response object response = None - esc = '' + esc = "" if json_response: fmt = f'["%(levelname)s","%(asctime)s","{esc}%(message)s{esc}"]' else: # Format is only updated if the content_type is html - fmt = '
  • ' \ - '
    %(asctime)s
    ' \ - '
    %(levelname)s
    ' \ - f'
    {esc}%(message)s{esc}
  • ' \ - if content_type == 'text/html' else \ - settings.LOGGING['formatters']['standard']['format'] + fmt = ( + '
  • ' + '
    %(asctime)s
    ' + '
    %(levelname)s
    ' + f'
    {esc}%(message)s{esc}
  • ' + if content_type == "text/html" + else settings.LOGGING["formatters"]["standard"]["format"] + ) # Now specify our format (and over-ride the default): with apprise.LogCapture(level=level, fmt=fmt) as logs: # Perform our notification at this point result = a_obj.notify( - content.get('body'), - title=content.get('title', ''), - notify_type=content.get('type', apprise.NotifyType.INFO), - tag='all', + content.get("body"), + title=content.get("title", ""), + notify_type=content.get("type", apprise.NotifyType.INFO.value), + tag="all", attach=attach, ) @@ -1599,34 +2013,36 @@ class StatelessNotifyView(View): esc = re.escape(esc) entries = re.findall( r'\r*\n?(?P\["[^"]+","[^"]+",)"{}(?P.+?)' - r'{}"(?P\]\r*\n?$)(?=$|\r*\n?\["[^"]+","[^"]+","{})'.format( - esc, esc, esc), logs.getvalue(), re.DOTALL | re.MULTILINE) + r'{}"(?P\]\r*\n?$)(?=$|\r*\n?\["[^"]+","[^"]+","{})'.format(esc, esc, esc), + logs.getvalue(), + re.DOTALL | re.MULTILINE, + ) # Prepare ourselves a JSON Response - response = json.loads('[{}]'.format( - ','.join([e[0] + json.dumps(e[1].rstrip()) + e[2] for e in entries]))) + response = json.loads("[{}]".format(",".join([e[0] + json.dumps(e[1].rstrip()) + e[2] for e in entries]))) - elif content_type == 'text/html': + elif content_type == "text/html": # Iterate over our entries so that we can prepare to escape # things to be presented as HTML esc = re.escape(esc) entries = re.findall( r'\r*\n?(?P
  • \r*\n?$)(?=$|\r*\n?
  • {}'.format( - ''.join([e[0] + escape(e[1]) + e[2] for e in entries])) + response = '
      {}
    '.format("".join([e[0] + escape(e[1]) + e[2] for e in entries])) else: # content_type == 'text/plain' response = logs.getvalue() if settings.APPRISE_WEBHOOK_URL: webhook_payload = { - 'source': request.META['REMOTE_ADDR'], - 'status': 0 if result else 1, - 'output': response, + "source": request.META["REMOTE_ADDR"], + "status": 0 if result else 1, + "output": response, } # Send our webhook (pass or fail) @@ -1636,36 +2052,59 @@ class StatelessNotifyView(View): # If at least one notification couldn't be sent; change up the # response to a 424 error code logger.warning( - 'NOTIFY - %s - One or more stateless notification(s) could not be actioned', - request.META['REMOTE_ADDR']) + "NOTIFY - %s - One or more stateless notification(s) could not be actioned", + request.META["REMOTE_ADDR"], + ) status = ResponseCode.failed_dependency - msg = _('One or more notifications could not be sent') + msg = _("One or more notifications could not be sent") - return HttpResponse(response if response else msg, status=status, content_type=content_type) \ - if not json_response else JsonResponse({ - 'error': msg, - 'details': response, - }, encoder=JSONEncoder, safe=False, status=status) + return ( + HttpResponse( + response if response else msg, + status=status, + content_type=content_type, + ) + if not json_response + else JsonResponse( + { + "error": msg, + "details": response, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) logger.info( - 'NOTIFY - %s - Delivered Stateless Notification(s)', - request.META['REMOTE_ADDR']) + "NOTIFY - %s - Delivered Stateless Notification(s)", + request.META["REMOTE_ADDR"], + ) # Return our success message status = ResponseCode.okay - return HttpResponse(response, status=status, content_type=content_type) \ - if not json_response else JsonResponse({ - 'error': None, - 'details': response, - }, encoder=JSONEncoder, safe=False, status=status) + return ( + HttpResponse(response, status=status, content_type=content_type) + if not json_response + else JsonResponse( + { + "error": None, + "details": response, + }, + encoder=JSONEncoder, + safe=False, + status=status, + ) + ) -@method_decorator((gzip_page, never_cache), name='dispatch') +@method_decorator((gzip_page, never_cache), name="dispatch") class JsonUrlView(View): """ A Django view that lists all loaded tags and URLs for a given key """ + def get(self, request, key): """ Handle a POST request @@ -1688,18 +2127,23 @@ class JsonUrlView(View): # ] # } response = { - 'tags': set(), - 'urls': [], + "tags": set(), + "urls": [], } # Privacy flag # Support 'yes', '1', 'true', 'enable', 'active', and + - privacy = settings.APPRISE_CONFIG_LOCK or \ - request.GET.get('privacy', 'no')[0].lower() in ( - 'a', 'y', '1', 't', 'e', '+') + privacy = settings.APPRISE_CONFIG_LOCK or request.GET.get("privacy", "no")[0].lower() in ( + "a", + "y", + "1", + "t", + "e", + "+", + ) # Optionally filter on tags. Use comma to identify more then one - tag = request.GET.get('tag', 'all') + tag = request.GET.get("tag", "all") config, format = ConfigCache.get(key) if config is None: @@ -1719,7 +2163,7 @@ class JsonUrlView(View): ) # Something went very wrong; return 500 - response['error'] = _('There was no configuration found') + response["error"] = _("There was no configuration found") return JsonResponse( response, encoder=JSONEncoder, @@ -1741,19 +2185,16 @@ class JsonUrlView(View): for notification in a_obj.find(tag): # Set Notification - response['urls'].append({ - 'id': notification.url_id(), - 'url': notification.url(privacy=privacy), - 'tags': notification.tags, - }) + response["urls"].append( + { + "id": notification.url_id(), + "url": notification.url(privacy=privacy), + "tags": notification.tags, + } + ) # Store Tags - response['tags'] |= notification.tags + response["tags"] |= notification.tags # Return our retrieved content - return JsonResponse( - response, - encoder=JSONEncoder, - safe=False, - status=ResponseCode.okay - ) + return JsonResponse(response, encoder=JSONEncoder, safe=False, status=ResponseCode.okay) diff --git a/apprise_api/core/context_processors.py b/apprise_api/core/context_processors.py index a8c7205..614461a 100644 --- a/apprise_api/core/context_processors.py +++ b/apprise_api/core/context_processors.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2020 Chris Caron # All rights reserved. @@ -30,7 +29,7 @@ def base_url(request): Returns our defined BASE_URL object """ return { - 'BASE_URL': settings.BASE_URL, - 'CONFIG_DIR': settings.APPRISE_CONFIG_DIR, - 'ATTACH_DIR': settings.APPRISE_ATTACH_DIR, + "BASE_URL": settings.BASE_URL, + "CONFIG_DIR": settings.APPRISE_CONFIG_DIR, + "ATTACH_DIR": settings.APPRISE_ATTACH_DIR, } diff --git a/apprise_api/core/middleware/config.py b/apprise_api/core/middleware/config.py index a014bbb..d49c733 100644 --- a/apprise_api/core/middleware/config.py +++ b/apprise_api/core/middleware/config.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2023 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # 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 # THE SOFTWARE. # -import re -from django.conf import settings import datetime +import re + +from django.conf import settings class DetectConfigMiddleware: @@ -35,7 +35,7 @@ class DetectConfigMiddleware: """ - _is_cfg_path = re.compile(r'/cfg/(?P[\w_-]{1,128})') + _is_cfg_path = re.compile(r"/cfg/(?P[\w_-]{1,128})") def __init__(self, get_response): """ @@ -51,14 +51,13 @@ class DetectConfigMiddleware: result = self._is_cfg_path.match(request.path) if not result: # Our current config - config = \ - request.COOKIES.get('key', settings.APPRISE_DEFAULT_CONFIG_ID) + config = request.COOKIES.get("key", settings.APPRISE_DEFAULT_CONFIG_ID) # Extract our key (fall back to our default if not set) config = request.GET.get("key", config).strip() else: - config = result.group('key') + config = result.group("key") if not config: # Fallback to default config @@ -72,11 +71,10 @@ class DetectConfigMiddleware: # Set our cookie max_age = 365 * 24 * 60 * 60 # 1 year - expires = datetime.datetime.utcnow() + \ - datetime.timedelta(seconds=max_age) + expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=max_age) # Set our cookie - response.set_cookie('key', config, expires=expires) + response.set_cookie("key", config, expires=expires) # return our response return response diff --git a/apprise_api/core/middleware/theme.py b/apprise_api/core/middleware/theme.py index a00f3b9..b7de549 100644 --- a/apprise_api/core/middleware/theme.py +++ b/apprise_api/core/middleware/theme.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2023 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # 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 # THE SOFTWARE. # -from django.conf import settings -from core.themes import SiteTheme, SITE_THEMES import datetime +from django.conf import settings + +from core.themes import SITE_THEMES, SiteTheme + class AutoThemeMiddleware: """ @@ -47,15 +48,18 @@ class AutoThemeMiddleware: """ # Our current theme - current_theme = \ - request.COOKIES.get('t', request.COOKIES.get( - 'theme', settings.APPRISE_DEFAULT_THEME)) + current_theme = request.COOKIES.get("t", request.COOKIES.get("theme", settings.APPRISE_DEFAULT_THEME)) # Extract our theme (fall back to our default if not set) theme = request.GET.get("theme", current_theme).strip().lower() - theme = next((entry for entry in SITE_THEMES - if entry.startswith(theme)), None) \ - if theme else None + theme = ( + next( + (entry for entry in SITE_THEMES if entry.startswith(theme)), + None, + ) + if theme + else None + ) if theme not in SITE_THEMES: # Fallback to default theme @@ -65,20 +69,17 @@ class AutoThemeMiddleware: request.theme = theme # Set our next theme - request.next_theme = SiteTheme.LIGHT \ - if theme == SiteTheme.DARK \ - else SiteTheme.DARK + request.next_theme = SiteTheme.LIGHT if theme == SiteTheme.DARK else SiteTheme.DARK # Get our response object response = self.get_response(request) # Set our cookie max_age = 365 * 24 * 60 * 60 # 1 year - expires = datetime.datetime.utcnow() + \ - datetime.timedelta(seconds=max_age) + expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=max_age) # Set our cookie - response.set_cookie('theme', theme, expires=expires) + response.set_cookie("theme", theme, expires=expires) # return our response return response diff --git a/apprise_api/core/settings/__init__.py b/apprise_api/core/settings/__init__.py index 37de69d..bdfe65a 100644 --- a/apprise_api/core/settings/__init__.py +++ b/apprise_api/core/settings/__init__.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2023 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # 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 # THE SOFTWARE. import os + from core.themes import SiteTheme # Disable Timezones USE_TZ = False # Base Directory (relative to settings) -BASE_DIR = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get( - 'SECRET_KEY', '+reua88v8rs4j!bcfdtinb-f0edxazf!$x_q1g7jtgckxd7gi=') +SECRET_KEY = os.environ.get("SECRET_KEY", "+reua88v8rs4j!bcfdtinb-f0edxazf!$x_q1g7jtgckxd7gi=") # SECURITY WARNING: don't run with debug turned on in production! # 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 # # Support 'yes', '1', 'true', 'enable', 'active', and + -DEBUG = os.environ.get("DEBUG", 'No')[0].lower() in ( - 'a', 'y', '1', 't', 'e', '+') +DEBUG = os.environ.get("DEBUG", "No")[0].lower() in ( + "a", + "y", + "1", + "t", + "e", + "+", +) # allow all hosts by default otherwise read from the # ALLOWED_HOSTS environment variable -ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(' ') +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(" ") # Application definition INSTALLED_APPS = [ - 'django.contrib.contenttypes', - 'django.contrib.staticfiles', - + "django.contrib.contenttypes", + "django.contrib.staticfiles", # Apprise API - 'api', - + "api", # Prometheus - 'django_prometheus', + "django_prometheus", ] MIDDLEWARE = [ - 'django_prometheus.middleware.PrometheusBeforeMiddleware', - 'django.middleware.common.CommonMiddleware', - 'core.middleware.theme.AutoThemeMiddleware', - 'core.middleware.config.DetectConfigMiddleware', - 'django_prometheus.middleware.PrometheusAfterMiddleware', + "django_prometheus.middleware.PrometheusBeforeMiddleware", + "django.middleware.common.CommonMiddleware", + "core.middleware.theme.AutoThemeMiddleware", + "core.middleware.config.DetectConfigMiddleware", + "django_prometheus.middleware.PrometheusAfterMiddleware", ] -ROOT_URLCONF = 'core.urls' +ROOT_URLCONF = "core.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'core.context_processors.base_url', - 'api.context_processors.default_config_id', - 'api.context_processors.unique_config_id', - 'api.context_processors.stateful_mode', - 'api.context_processors.config_lock', - 'api.context_processors.admin_enabled', - 'api.context_processors.apprise_version', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "core.context_processors.base_url", + "api.context_processors.default_config_id", + "api.context_processors.unique_config_id", + "api.context_processors.stateful_mode", + "api.context_processors.config_lock", + "api.context_processors.admin_enabled", + "api.context_processors.apprise_version", ], }, }, ] LOGGING = { - 'version': 1, - 'disable_existing_loggers': True, - 'formatters': { - 'standard': { - 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s' + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "standard": {"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 # 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 -APPRISE_DEFAULT_CONFIG_ID = \ - os.environ.get('APPRISE_DEFAULT_CONFIG_ID', 'apprise') +APPRISE_DEFAULT_CONFIG_ID = os.environ.get("APPRISE_DEFAULT_CONFIG_ID", "apprise") # Define our Prometheus Namespace PROMETHEUS_METRIC_NAMESPACE = "apprise" # 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' -APPRISE_DEFAULT_THEME = \ - os.environ.get('APPRISE_DEFAULT_THEME', SiteTheme.LIGHT) +APPRISE_DEFAULT_THEME = os.environ.get("APPRISE_DEFAULT_THEME", SiteTheme.LIGHT) # Webhook that is posted to upon executed results # Set it to something like https://myserver.com/path/ # 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 -APPRISE_CONFIG_DIR = os.environ.get( - 'APPRISE_CONFIG_DIR', os.path.join(BASE_DIR, 'var', 'config')) +APPRISE_CONFIG_DIR = os.environ.get("APPRISE_CONFIG_DIR", os.path.join(BASE_DIR, "var", "config")) # The location to store Apprise Persistent Storage files -APPRISE_STORAGE_DIR = os.environ.get( - 'APPRISE_STORAGE_DIR', os.path.join(APPRISE_CONFIG_DIR, 'store')) +APPRISE_STORAGE_DIR = os.environ.get("APPRISE_STORAGE_DIR", os.path.join(APPRISE_CONFIG_DIR, "store")) # Default number of days to prune persistent storage -APPRISE_STORAGE_PRUNE_DAYS = \ - int(os.environ.get('APPRISE_STORAGE_PRUNE_DAYS', 30)) +APPRISE_STORAGE_PRUNE_DAYS = int(os.environ.get("APPRISE_STORAGE_PRUNE_DAYS", 30)) # The default URL ID Length -APPRISE_STORAGE_UID_LENGTH = \ - int(os.environ.get('APPRISE_STORAGE_UID_LENGTH', 8)) +APPRISE_STORAGE_UID_LENGTH = int(os.environ.get("APPRISE_STORAGE_UID_LENGTH", 8)) # The default storage mode; options are: # - 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 # produces more i/o but can allow multiple calls to the same # 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 -APPRISE_ATTACH_DIR = os.environ.get( - 'APPRISE_ATTACH_DIR', os.path.join(BASE_DIR, 'var', 'attach')) +APPRISE_ATTACH_DIR = os.environ.get("APPRISE_ATTACH_DIR", os.path.join(BASE_DIR, "var", "attach")) # 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 # 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. # - Set the list to * (a single astrix) to match all URLs and accepting all provided # matches -APPRISE_ATTACH_DENY_URLS = \ - os.environ.get('APPRISE_ATTACH_REJECT_URL', '127.0.* localhost*').lower() +APPRISE_ATTACH_DENY_URLS = os.environ.get("APPRISE_ATTACH_REJECT_URL", "127.0.* localhost*").lower() # The Allow list which is processed after the Deny list above -APPRISE_ATTACH_ALLOW_URLS = \ - os.environ.get('APPRISE_ATTACH_ALLOW_URL', '*').lower() +APPRISE_ATTACH_ALLOW_URLS = os.environ.get("APPRISE_ATTACH_ALLOW_URL", "*").lower() # The maximum size in bytes that a request body may be before raising an error # (defined in MB) -DATA_UPLOAD_MAX_MEMORY_SIZE = abs(int(os.environ.get( - 'APPRISE_UPLOAD_MAX_MEMORY_SIZE', 3))) * 1048576 +DATA_UPLOAD_MAX_MEMORY_SIZE = abs(int(os.environ.get("APPRISE_UPLOAD_MAX_MEMORY_SIZE", 3))) * 1048576 # When set Apprise API Locks itself down so that future (configuration) # 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 # and do not want this information exposed any more then it needs to be. # it's a lock down mode if you will. -APPRISE_CONFIG_LOCK = \ - os.environ.get("APPRISE_CONFIG_LOCK", 'no')[0].lower() in ( - 'a', 'y', '1', 't', 'e', '+') +APPRISE_CONFIG_LOCK = 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 # 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 # By default this is set to no -APPRISE_STATELESS_STORAGE = \ - os.environ.get("APPRISE_STATELESS_STORAGE", 'no')[0].lower() in ( - 'a', 'y', '1', 't', 'e', '+') +APPRISE_STATELESS_STORAGE = os.environ.get("APPRISE_STATELESS_STORAGE", "no")[0].lower() in ( + "a", + "y", + "1", + "t", + "e", + "+", +) # Defines the stateful mode; possible values are: # - hash (default): content is hashed and zipped # - simple: content is just written straight to disk 'as-is' # - 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 # - By default we disable all non-remote calling services # - 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 # xml, that would include the xmls entry as well (or vs versa) -APPRISE_DENY_SERVICES = os.environ.get('APPRISE_DENY_SERVICES', ','.join(( - 'windows', 'dbus', 'gnome', 'macosx', 'syslog'))) +APPRISE_DENY_SERVICES = os.environ.get( + "APPRISE_DENY_SERVICES", + ",".join(("windows", "dbus", "gnome", "macosx", "syslog")), +) # Our Apprise Exclusive Allow List # - anything not identified here is denied/disabled) # - 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 # 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 # 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 -APPRISE_PLUGIN_PATHS = os.environ.get( - 'APPRISE_PLUGIN_PATHS', os.path.join(BASE_DIR, 'var', 'plugin')).split(',') +APPRISE_PLUGIN_PATHS = os.environ.get("APPRISE_PLUGIN_PATHS", os.path.join(BASE_DIR, "var", "plugin")).split(",") # Define the number of attachments that can exist as part of a payload # 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: # - showing a list of configuration keys (when STATEFUL_MODE is set to simple) -APPRISE_ADMIN = \ - os.environ.get("APPRISE_ADMIN", 'no')[0].lower() in ( - 'a', 'y', '1', 't', 'e', '+') +APPRISE_ADMIN = os.environ.get("APPRISE_ADMIN", "no")[0].lower() in ( + "a", + "y", + "1", + "t", + "e", + "+", +) diff --git a/apprise_api/core/settings/debug/__init__.py b/apprise_api/core/settings/debug/__init__.py index 4204b0c..688b321 100644 --- a/apprise_api/core/settings/debug/__init__.py +++ b/apprise_api/core/settings/debug/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -26,6 +25,7 @@ # To create a valid debug settings.py we need to intentionally pollute our # file with all of the content found in the master configuration. import os + from .. import * # noqa F403 # Debug is always on when running in debug mode @@ -35,9 +35,7 @@ DEBUG = True ALLOWED_HOSTS = [] # 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 -STATICFILES_DIRS = ( - os.path.join(BASE_DIR, 'static'), # noqa F405 -) +STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa F405 diff --git a/apprise_api/core/settings/debug/urls.py b/apprise_api/core/settings/debug/urls.py index 12035e4..b3ef146 100644 --- a/apprise_api/core/settings/debug/urls.py +++ b/apprise_api/core/settings/debug/urls.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -24,6 +23,7 @@ # THE SOFTWARE. from django.conf import settings from django.conf.urls.static import static + from ...urls import * # noqa F403 # Extend our patterns diff --git a/apprise_api/core/settings/pytest/__init__.py b/apprise_api/core/settings/pytest/__init__.py index 030ed0f..da99511 100644 --- a/apprise_api/core/settings/pytest/__init__.py +++ b/apprise_api/core/settings/pytest/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -26,6 +25,7 @@ # To create a valid debug settings.py we need to intentionally pollute our # file with all of the content found in the master configuration. from tempfile import TemporaryDirectory + from .. import * # noqa F403 # Debug is always on when running in debug mode @@ -38,4 +38,4 @@ ALLOWED_HOSTS = [] APPRISE_CONFIG_DIR = TemporaryDirectory().name # Setup our runner -TEST_RUNNER = 'core.settings.pytest.runner.PytestTestRunner' +TEST_RUNNER = "core.settings.pytest.runner.PytestTestRunner" diff --git a/apprise_api/core/settings/pytest/runner.py b/apprise_api/core/settings/pytest/runner.py index c4f8c41..552b819 100644 --- a/apprise_api/core/settings/pytest/runner.py +++ b/apprise_api/core/settings/pytest/runner.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -27,7 +26,7 @@ # file with all of the content found in the master configuration. -class PytestTestRunner(object): +class PytestTestRunner: """Runs pytest to discover and run tests.""" def __init__(self, verbosity=1, failfast=False, keepdb=False, **kwargs): @@ -44,15 +43,15 @@ class PytestTestRunner(object): argv = [] if self.verbosity == 0: - argv.append('--quiet') + argv.append("--quiet") if self.verbosity == 2: - argv.append('--verbose') + argv.append("--verbose") if self.verbosity == 3: - argv.append('-vv') + argv.append("-vv") if self.failfast: - argv.append('--exitfirst') + argv.append("--exitfirst") if self.keepdb: - argv.append('--reuse-db') + argv.append("--reuse-db") argv.extend(test_labels) return pytest.main(argv) diff --git a/apprise_api/core/themes.py b/apprise_api/core/themes.py index 5f5a77c..87be15a 100644 --- a/apprise_api/core/themes.py +++ b/apprise_api/core/themes.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -28,8 +27,9 @@ class SiteTheme: """ Defines our site themes """ - LIGHT = 'light' - DARK = 'dark' + + LIGHT = "light" + DARK = "dark" SITE_THEMES = ( diff --git a/apprise_api/core/urls.py b/apprise_api/core/urls.py index 04e87fe..bec0d3a 100644 --- a/apprise_api/core/urls.py +++ b/apprise_api/core/urls.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -22,12 +21,11 @@ # 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.urls import path -from django.conf.urls import include - from api import urls as api_urls +from django.conf.urls import include +from django.urls import path urlpatterns = [ - path('', include(api_urls)), - path('', include('django_prometheus.urls')), + path("", include(api_urls)), + path("", include("django_prometheus.urls")), ] diff --git a/apprise_api/core/wsgi.py b/apprise_api/core/wsgi.py index c7fc4a4..874426a 100644 --- a/apprise_api/core/wsgi.py +++ b/apprise_api/core/wsgi.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -23,7 +22,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import os + 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() diff --git a/apprise_api/gunicorn.conf.py b/apprise_api/gunicorn.conf.py index 422d3b3..8b342a6 100644 --- a/apprise_api/gunicorn.conf.py +++ b/apprise_api/gunicorn.conf.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- # -# Copyright (C) 2023 Chris Caron +# Copyright (C) 2025 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. @@ -22,36 +21,35 @@ # 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. -import os import multiprocessing +import os # This file is launched with the call: # gunicorn --config core.wsgi:application raw_env = [ - 'LANG={}'.format(os.environ.get('LANG', 'en_US.UTF-8')), - 'DJANGO_SETTINGS_MODULE=core.settings', + "LANG={}".format(os.environ.get("LANG", "en_US.UTF-8")), + "DJANGO_SETTINGS_MODULE=core.settings", ] # This is the path as prepared in the docker compose -pythonpath = '/opt/apprise/webapp' +pythonpath = "/opt/apprise/webapp" # bind to port 8000 bind = [ - '0.0.0.0:8000', # IPv4 Support - '[::]:8000', # IPv6 Support + "0.0.0.0:8000", # IPv4 Support + "[::]:8000", # IPv6 Support ] # Workers are relative to the number of CPU's provided by hosting server -workers = int(os.environ.get( - 'APPRISE_WORKER_COUNT', multiprocessing.cpu_count() * 2 + 1)) +workers = int(os.environ.get("APPRISE_WORKER_COUNT", multiprocessing.cpu_count() * 2 + 1)) # Increase worker timeout value to give upstream services time to # 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` -worker_class = 'gevent' +worker_class = "gevent" # Get workers memory consumption under control by leveraging gunicorn # worker recycling timeout @@ -60,6 +58,6 @@ max_requests_jitter = 50 # Logging # '-' means log to stdout. -errorlog = '-' -accesslog = '-' -loglevel = 'warn' +errorlog = "-" +accesslog = "-" +loglevel = "warn" diff --git a/apprise_api/manage.py b/apprise_api/manage.py index d2141a6..6d7d805 100755 --- a/apprise_api/manage.py +++ b/apprise_api/manage.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -28,7 +27,7 @@ import sys def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.debug') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.debug") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -40,5 +39,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/dev-requirements.txt b/dev-requirements.txt index 734ae31..6e98eaf 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,6 +1,6 @@ -flake8 mock pytest-django pytest pytest-cov tox +ruff diff --git a/docker-compose.yml b/docker-compose.yml index 4942c28..3a1c863 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: - apprise: + apprise-api: build: . - container_name: apprise + container_name: apprise-api environment: - APPRISE_STATEFUL_MODE=simple ports: diff --git a/manage.py b/manage.py index b581595..4403840 100755 --- a/manage.py +++ b/manage.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # # Copyright (C) 2019 Chris Caron # All rights reserved. @@ -27,13 +26,12 @@ import os import sys # Update our path so it will see our apprise_api content -sys.path.insert( - 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'apprise_api')) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "apprise_api")) def main(): # 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: from django.core.management import execute_from_command_line except ImportError as exc: @@ -45,5 +43,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7dbf4ca --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,179 @@ +# +# Copyright (C) 2025 Chris Caron +# 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 = ["_"] + diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b2dd51a..0000000 --- a/setup.cfg +++ /dev/null @@ -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 diff --git a/tox.ini b/tox.ini index 2e7f67e..8b8851c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,30 +1,54 @@ [tox] -envlist = py312,coverage-report -skipsdist = true +envlist = + clean + test + lint + format + qa + coverage + +skip_missing_interpreters = true [testenv] -# Prevent random setuptools/pip breakages like -# https://github.com/pypa/setuptools/issues/1042 from breaking our builds. +skip_install = false +usedevelop = true +changedir = {toxinidir} +allowlist_externals = * +ensurepip = true setenv = - VIRTUALENV_NO_DOWNLOAD=1 -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 + COVERAGE_RCFILE = {toxinidir}/pyproject.toml -[testenv:py312] -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:lint] +description = Run Ruff in check-only mode +deps = ruff +commands = ruff check apprise_api -[testenv:coverage-report] -deps = coverage +[testenv:format] +description = Auto-format code using Ruff +deps = ruff skip_install = true -commands= - coverage combine apprise_api - coverage report apprise_api +commands = ruff check --fix 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 +