mirror of
https://github.com/caronc/apprise-api.git
synced 2025-08-18 18:38:10 +02:00
Refactored code base; modernized with pyproject.toml (#255)
This commit is contained in:
24
.coveragerc
24
.coveragerc
@@ -1,24 +0,0 @@
|
||||
[run]
|
||||
data_file = .coverage-reports/.coverage
|
||||
parallel = False
|
||||
concurrency = multiprocessing
|
||||
include = apprise_api
|
||||
omit =
|
||||
*apps.py,
|
||||
*/migrations/*,
|
||||
*/core/settings/*,
|
||||
*/*/tests/*,
|
||||
lib/*,
|
||||
lib64/*,
|
||||
*urls.py,
|
||||
*/core/wsgi.py,
|
||||
gunicorn.conf.py,
|
||||
*/manage.py
|
||||
|
||||
disable_warnings = no-data-collected
|
||||
|
||||
[report]
|
||||
show_missing = True
|
||||
skip_covered = True
|
||||
skip_empty = True
|
||||
fail_under = 75.0
|
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,9 +1,9 @@
|
||||
## Description:
|
||||
**Related issue (if applicable):** refs #<!--apprise issue number goes here-->
|
||||
**Related issue (if applicable):** refs #<!--apprise-api issue number goes here-->
|
||||
|
||||
## Checklist
|
||||
<!-- The following must be completed or your PR can't be merged -->
|
||||
* [ ] 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
|
||||
|
75
.github/workflows/tests.yml
vendored
75
.github/workflows/tests.yml
vendored
@@ -1,71 +1,55 @@
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# This code is licensed under the MIT License.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files(the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions :
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
name: Tests
|
||||
|
||||
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 }}
|
||||
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
.local/
|
||||
lib64
|
||||
lib
|
||||
var/
|
||||
|
@@ -2,7 +2,7 @@
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise-API - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
68
README.md
68
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.
|
||||
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
@@ -26,4 +25,4 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = 'api'
|
||||
name = "api"
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
||||
# 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()}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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,
|
||||
)
|
||||
|
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# 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']})"))
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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='<urls><url>mailto://user:pass@yahoo.ca</url></urls>',
|
||||
content_type='application/xml',
|
||||
"/add/{}".format(key),
|
||||
data="<urls><url>mailto://user:pass@yahoo.ca</url></urls>",
|
||||
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
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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, {})
|
||||
|
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# 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)
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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)
|
||||
|
52
apprise_api/api/tests/test_config_middleware.py
Normal file
52
apprise_api/api/tests/test_config_middleware.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# This code is licensed under the MIT License.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files(the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions :
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory, SimpleTestCase, override_settings
|
||||
|
||||
from apprise_api.core.middleware.config import DetectConfigMiddleware
|
||||
|
||||
|
||||
class DetectConfigMiddlewareTest(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
@override_settings(APPRISE_DEFAULT_CONFIG_ID="")
|
||||
def test_middleware_falls_back_when_config_absent(self):
|
||||
"""
|
||||
Ensure we trigger the `if not config:` fallback
|
||||
"""
|
||||
# Create request to path that does NOT match /cfg/<key>
|
||||
request = self.factory.get("/")
|
||||
request.COOKIES = {} # no 'key' cookie
|
||||
|
||||
# Patch middleware to capture the 'config' result
|
||||
def get_response(req):
|
||||
req._config = getattr(req, "_config", None)
|
||||
return HttpResponse() # simulate response
|
||||
|
||||
middleware = DetectConfigMiddleware(get_response)
|
||||
response = middleware(request)
|
||||
|
||||
# Validate we entered the `if not config:` branch
|
||||
# You can't test the internal `config` variable directly unless it's exposed
|
||||
self.assertTrue(hasattr(response, "_config") is False)
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# 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")
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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")
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
||||
# 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",
|
||||
],
|
||||
}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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")
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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'<ul class="logs">' in response.content
|
||||
assert b'class="logs"' in response.content
|
||||
|
||||
@mock.patch('apprise.Apprise.notify')
|
||||
@mock.patch("apprise.Apprise.notify")
|
||||
def test_notify_by_loaded_urls_with_json(self, mock_notify):
|
||||
"""
|
||||
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
|
||||
|
43
apprise_api/api/tests/test_theme_middleware.py
Normal file
43
apprise_api/api/tests/test_theme_middleware.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# This code is licensed under the MIT License.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files(the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions :
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory, SimpleTestCase
|
||||
|
||||
from apprise_api.core.middleware.theme import AutoThemeMiddleware, SiteTheme
|
||||
|
||||
|
||||
class AutoThemeMiddlewareTest(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_theme_fallback_when_invalid_theme_given(self):
|
||||
request = self.factory.get("/", {"theme": "foobar"})
|
||||
request.COOKIES = {}
|
||||
|
||||
def get_response(req):
|
||||
return HttpResponse()
|
||||
|
||||
middleware = AutoThemeMiddleware(get_response)
|
||||
_response = middleware(request)
|
||||
|
||||
self.assertEqual(request.theme, SiteTheme.LIGHT)
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# 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"))
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# 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:
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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<key>[\w_-]{1,128})/?$",
|
||||
views.ConfigView.as_view(),
|
||||
name="config",
|
||||
),
|
||||
re_path(r"^cfg/?$", views.ConfigListView.as_view(), name="config_list"),
|
||||
re_path(r"^add/(?P<key>[\w_-]{1,128})/?$", views.AddView.as_view(), name="add"),
|
||||
re_path(r"^del/(?P<key>[\w_-]{1,128})/?$", views.DelView.as_view(), name="del"),
|
||||
re_path(r"^get/(?P<key>[\w_-]{1,128})/?$", views.GetView.as_view(), name="get"),
|
||||
re_path(
|
||||
r'^status/?$',
|
||||
views.HealthCheckView.as_view(), name='health'),
|
||||
r"^notify/(?P<key>[\w_-]{1,128})/?$",
|
||||
views.NotifyView.as_view(),
|
||||
name="notify",
|
||||
),
|
||||
re_path(r"^notify/?$", views.StatelessNotifyView.as_view(), name="s_notify"),
|
||||
re_path(
|
||||
r'^details/?$',
|
||||
views.DetailsView.as_view(), name='details'),
|
||||
re_path(
|
||||
r'^cfg/(?P<key>[\w_-]{1,128})/?$',
|
||||
views.ConfigView.as_view(), name='config'),
|
||||
re_path(
|
||||
r'^cfg/?$',
|
||||
views.ConfigListView.as_view(), name='config_list'),
|
||||
re_path(
|
||||
r'^add/(?P<key>[\w_-]{1,128})/?$',
|
||||
views.AddView.as_view(), name='add'),
|
||||
re_path(
|
||||
r'^del/(?P<key>[\w_-]{1,128})/?$',
|
||||
views.DelView.as_view(), name='del'),
|
||||
re_path(
|
||||
r'^get/(?P<key>[\w_-]{1,128})/?$',
|
||||
views.GetView.as_view(), name='get'),
|
||||
re_path(
|
||||
r'^notify/(?P<key>[\w_-]{1,128})/?$',
|
||||
views.NotifyView.as_view(), name='notify'),
|
||||
re_path(
|
||||
r'^notify/?$',
|
||||
views.StatelessNotifyView.as_view(), name='s_notify'),
|
||||
re_path(
|
||||
r'^json/urls/(?P<key>[\w_-]{1,128})/?$',
|
||||
views.JsonUrlView.as_view(), name='json_urls'),
|
||||
r"^json/urls/(?P<key>[\w_-]{1,128})/?$",
|
||||
views.JsonUrlView.as_view(),
|
||||
name="json_urls",
|
||||
),
|
||||
]
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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<name>[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<name>[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<name>[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<name>[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
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
||||
# 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,
|
||||
}
|
||||
|
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# 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<key>[\w_-]{1,128})')
|
||||
_is_cfg_path = re.compile(r"/cfg/(?P<key>[\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
|
||||
|
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# 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
|
||||
|
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# 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",
|
||||
"+",
|
||||
)
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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"
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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)
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
@@ -28,8 +27,9 @@ class SiteTheme:
|
||||
"""
|
||||
Defines our site themes
|
||||
"""
|
||||
LIGHT = 'light'
|
||||
DARK = 'dark'
|
||||
|
||||
LIGHT = "light"
|
||||
DARK = "dark"
|
||||
|
||||
|
||||
SITE_THEMES = (
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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")),
|
||||
]
|
||||
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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()
|
||||
|
@@ -1,6 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# 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 <this file> 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"
|
||||
|
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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()
|
||||
|
@@ -1,6 +1,6 @@
|
||||
flake8
|
||||
mock
|
||||
pytest-django
|
||||
pytest
|
||||
pytest-cov
|
||||
tox
|
||||
ruff
|
||||
|
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
apprise:
|
||||
apprise-api:
|
||||
build: .
|
||||
container_name: apprise
|
||||
container_name: apprise-api
|
||||
environment:
|
||||
- APPRISE_STATEFUL_MODE=simple
|
||||
ports:
|
||||
|
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
||||
# 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()
|
||||
|
179
pyproject.toml
Normal file
179
pyproject.toml
Normal file
@@ -0,0 +1,179 @@
|
||||
#
|
||||
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# This code is licensed under the MIT License.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files(the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions :
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "apprise-api"
|
||||
version = "1.2.1"
|
||||
authors = [{ name = "Chris Caron", email = "lead2gold@gmail.com" }]
|
||||
license = "MIT"
|
||||
dependencies = [
|
||||
"apprise == 1.9.4",
|
||||
"django",
|
||||
"gevent",
|
||||
"gunicorn",
|
||||
"requests",
|
||||
"paho-mqtt < 2.0.0",
|
||||
"gntp",
|
||||
"cryptography",
|
||||
"django-prometheus"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-django",
|
||||
"ruff",
|
||||
"tox",
|
||||
"mock"
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["apprise_api*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "core.settings.pytest"
|
||||
addopts = "--ignore=lib --ignore=lib64 --nomigrations --cov=apprise_api --cov-report=term-missing"
|
||||
filterwarnings = ["once::Warning"]
|
||||
testpaths = ["apprise_api"]
|
||||
|
||||
[tool.coverage.run]
|
||||
data_file = ".coverage-reports/.coverage"
|
||||
parallel = false
|
||||
concurrency = ["multiprocessing"]
|
||||
include = ["apprise_api"]
|
||||
omit = [
|
||||
"*apps.py",
|
||||
"*/migrations/*",
|
||||
"*/core/settings/*",
|
||||
"*/*/tests/*",
|
||||
"lib/*",
|
||||
"lib64/*",
|
||||
"*urls.py",
|
||||
"*/core/wsgi.py",
|
||||
"gunicorn.conf.py",
|
||||
"*/manage.py"
|
||||
]
|
||||
disable_warnings = ["no-data-collected"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_covered = true
|
||||
skip_empty = true
|
||||
fail_under = 75.0
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py312"
|
||||
exclude = [
|
||||
"tests/data",
|
||||
"bin",
|
||||
"build",
|
||||
"dist",
|
||||
".eggs",
|
||||
".tox",
|
||||
".local",
|
||||
".venv",
|
||||
"venv",
|
||||
]
|
||||
|
||||
[tool.black]
|
||||
# Added for backwards support and alternative cleanup
|
||||
# when needed
|
||||
line-length = 79 # Respecting BSD-style 79-char limit
|
||||
target-version = ['py39']
|
||||
extend-exclude = '''
|
||||
/(
|
||||
tests/data \
|
||||
bin \
|
||||
build \
|
||||
dist \
|
||||
.eggs \
|
||||
.tox \
|
||||
.local \
|
||||
.venv \
|
||||
venv
|
||||
)
|
||||
'''
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"F", # pyflakes
|
||||
"W", # pycodestyle warnings
|
||||
"Q", # Quote handling ' -> "
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"SIM", # flake8-simplify
|
||||
"T20", # flake8-print (catches stray `print`)
|
||||
"RUF", # Ruff-native rules
|
||||
]
|
||||
|
||||
extend-select = [
|
||||
"E501", # Spacing
|
||||
"Q000", # Quoting
|
||||
"I" # Automatically sort imports with isort logic
|
||||
]
|
||||
|
||||
ignore = [
|
||||
"D100", # missing docstring in public module
|
||||
"D104", # missing docstring in public package
|
||||
"B008", # do not call `dict()` with keyword args
|
||||
"E722", # bare except (Apprise uses it reasonably)
|
||||
"E741", # Ambiguous variable name (e.g., l, O, I)
|
||||
"W605", # Invalid escape sequence
|
||||
"B026", # Star-arg play a big part of Apprise; must be accepted for now
|
||||
|
||||
# The following needs to be supported at some point and is of great value
|
||||
"RUF012", # typing.ClassVar implimentation
|
||||
"UP032", # This is a massive undertaking and requires a massive rewrite
|
||||
# of test cases; this will take a while to fix, so turning off
|
||||
# for now
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pyupgrade]
|
||||
keep-runtime-typing = true
|
||||
|
||||
[tool.ruff.lint.flake8-quotes]
|
||||
inline-quotes = "double"
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["apprise"]
|
||||
force-sort-within-sections = true
|
||||
combine-as-imports = true
|
||||
order-by-type = true
|
||||
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
|
||||
|
||||
[tool.ruff.lint.flake8-builtins]
|
||||
builtins-ignorelist = ["_"]
|
||||
|
20
setup.cfg
20
setup.cfg
@@ -1,20 +0,0 @@
|
||||
[metadata]
|
||||
# ensure LICENSE is included in wheel metadata
|
||||
license_file = LICENSE
|
||||
|
||||
[flake8]
|
||||
# We exclude packages we don't maintain
|
||||
exclude = .eggs,.tox
|
||||
ignore = E741,E722,W503,W504,W605
|
||||
statistics = true
|
||||
builtins = _
|
||||
max-line-length = 160
|
||||
|
||||
[aliases]
|
||||
test=pytest
|
||||
|
||||
[tool:pytest]
|
||||
DJANGO_SETTINGS_MODULE = core.settings.pytest
|
||||
addopts = --ignore=lib --ignore=lib64 --nomigrations --cov=apprise_api --cov-report=term-missing
|
||||
filterwarnings =
|
||||
once::Warning
|
70
tox.ini
70
tox.ini
@@ -1,30 +1,54 @@
|
||||
[tox]
|
||||
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
|
||||
|
||||
|
Reference in New Issue
Block a user