mirror of
https://github.com/caronc/apprise-api.git
synced 2025-08-16 17:40:56 +02:00
Persistent Storage Support (Apprise v1.9.0+) Added
This commit is contained in:
0
apprise_api/api/management/__init__.py
Normal file
0
apprise_api/api/management/__init__.py
Normal file
0
apprise_api/api/management/commands/__init__.py
Normal file
0
apprise_api/api/management/commands/__init__.py
Normal file
45
apprise_api/api/management/commands/storeprune.py
Normal file
45
apprise_api/api/management/commands/storeprune.py
Normal file
@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 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.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
import apprise
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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"])
|
||||
)
|
@ -351,8 +351,14 @@ async function main_init(){
|
||||
let code = document.createElement('code');
|
||||
let li = document.createElement('li');
|
||||
code.textContent = entry.url;
|
||||
li.setAttribute('class', 'card-panel');
|
||||
li.appendChild(code);
|
||||
li.setAttribute('class', 'card-panel');
|
||||
if (entry.id) {
|
||||
let url_id = document.createElement('code');
|
||||
url_id.setAttribute('class', 'url-id');
|
||||
url_id.textContent = entry.id;
|
||||
li.appendChild(url_id);
|
||||
}
|
||||
|
||||
urlList.appendChild(li);
|
||||
// Store `all` tag
|
||||
|
35
apprise_api/api/tests/test_cli.py
Normal file
35
apprise_api/api/tests/test_cli.py
Normal file
@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023 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.
|
||||
|
||||
import io
|
||||
from django.test import SimpleTestCase
|
||||
from django.core import management
|
||||
|
||||
|
||||
class CommandTests(SimpleTestCase):
|
||||
|
||||
def test_command_style(self):
|
||||
out = io.StringIO()
|
||||
management.call_command('storeprune', days=40, stdout=out)
|
@ -66,6 +66,7 @@ class HealthCheckTests(SimpleTestCase):
|
||||
'config_lock': False,
|
||||
'attach_lock': False,
|
||||
'status': {
|
||||
'persistent_storage': True,
|
||||
'can_write_config': True,
|
||||
'can_write_attach': True,
|
||||
'details': ['OK']
|
||||
@ -90,6 +91,7 @@ class HealthCheckTests(SimpleTestCase):
|
||||
'config_lock': True,
|
||||
'attach_lock': False,
|
||||
'status': {
|
||||
'persistent_storage': True,
|
||||
'can_write_config': False,
|
||||
'can_write_attach': True,
|
||||
'details': ['OK']
|
||||
@ -113,6 +115,7 @@ class HealthCheckTests(SimpleTestCase):
|
||||
'config_lock': False,
|
||||
'attach_lock': False,
|
||||
'status': {
|
||||
'persistent_storage': True,
|
||||
'can_write_config': False,
|
||||
'can_write_attach': True,
|
||||
'details': ['OK']
|
||||
@ -136,6 +139,7 @@ class HealthCheckTests(SimpleTestCase):
|
||||
'config_lock': False,
|
||||
'attach_lock': True,
|
||||
'status': {
|
||||
'persistent_storage': True,
|
||||
'can_write_config': True,
|
||||
'can_write_attach': False,
|
||||
'details': ['OK']
|
||||
@ -159,6 +163,7 @@ class HealthCheckTests(SimpleTestCase):
|
||||
'config_lock': False,
|
||||
'attach_lock': False,
|
||||
'status': {
|
||||
'persistent_storage': True,
|
||||
'can_write_config': True,
|
||||
'can_write_attach': True,
|
||||
'details': ['OK']
|
||||
@ -172,6 +177,7 @@ class HealthCheckTests(SimpleTestCase):
|
||||
|
||||
result = healthcheck(lazy=True)
|
||||
assert result == {
|
||||
'persistent_storage': True,
|
||||
'can_write_config': True,
|
||||
'can_write_attach': True,
|
||||
'details': ['OK']
|
||||
@ -180,6 +186,7 @@ class HealthCheckTests(SimpleTestCase):
|
||||
# A Double lazy check
|
||||
result = healthcheck(lazy=True)
|
||||
assert result == {
|
||||
'persistent_storage': True,
|
||||
'can_write_config': True,
|
||||
'can_write_attach': True,
|
||||
'details': ['OK']
|
||||
@ -192,16 +199,69 @@ class HealthCheckTests(SimpleTestCase):
|
||||
# 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'],
|
||||
}
|
||||
|
||||
# Force a lazy check where we can't acquire the modify time
|
||||
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',
|
||||
]}
|
||||
|
||||
# Force a non-lazy check
|
||||
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',
|
||||
]}
|
||||
|
||||
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):
|
||||
result = healthcheck(lazy=False)
|
||||
assert result == {
|
||||
'persistent_storage': False,
|
||||
'can_write_config': True,
|
||||
'can_write_attach': True,
|
||||
'details': [
|
||||
'STORE_PERMISSION_ISSUE',
|
||||
]}
|
||||
|
||||
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': [
|
||||
@ -209,18 +269,10 @@ class HealthCheckTests(SimpleTestCase):
|
||||
'ATTACH_PERMISSION_ISSUE',
|
||||
]}
|
||||
|
||||
mock_makedirs.side_effect = (None, OSError())
|
||||
result = healthcheck(lazy=False)
|
||||
assert result == {
|
||||
'can_write_config': True,
|
||||
'can_write_attach': False,
|
||||
'details': [
|
||||
'ATTACH_PERMISSION_ISSUE',
|
||||
]}
|
||||
|
||||
mock_makedirs.side_effect = (OSError(), None)
|
||||
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': [
|
||||
|
63
apprise_api/api/tests/test_utils.py
Normal file
63
apprise_api/api/tests/test_utils.py
Normal file
@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2024 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.
|
||||
import os
|
||||
import mock
|
||||
import tempfile
|
||||
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=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 True
|
||||
|
||||
# Date is updated
|
||||
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is True
|
||||
|
||||
with mock.patch('os.utime', side_effect=OSError()):
|
||||
# Fails to update file
|
||||
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
|
@ -207,6 +207,26 @@ class HTTPAttachment(A_MGR['http']):
|
||||
pass
|
||||
|
||||
|
||||
def touchdir(path, mode=0o770, **kwargs):
|
||||
"""
|
||||
Acts like a Linux touch and updates a dir with a current timestamp
|
||||
"""
|
||||
try:
|
||||
os.makedirs(path, mode=mode, exist_ok=False)
|
||||
|
||||
except FileExistsError:
|
||||
# Update the mtime of the directory
|
||||
try:
|
||||
os.utime(path, None)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def touch(fname, mode=0o666, dir_fd=None, **kwargs):
|
||||
"""
|
||||
Acts like a Linux touch and updates a file with a current timestamp
|
||||
@ -778,6 +798,7 @@ def healthcheck(lazy=True):
|
||||
|
||||
# Some status variables we can flip
|
||||
response = {
|
||||
'persistent_storage': False,
|
||||
'can_write_config': False,
|
||||
'can_write_attach': False,
|
||||
'details': [],
|
||||
@ -853,6 +874,45 @@ def healthcheck(lazy=True):
|
||||
# We can take an early exit
|
||||
response['details'].append('ATTACH_PERMISSION_ISSUE')
|
||||
|
||||
if settings.APPRISE_STORAGE_DIR:
|
||||
#
|
||||
# Persistent Storage Check
|
||||
#
|
||||
store = apprise.PersistentStore(
|
||||
path=settings.APPRISE_STORAGE_DIR,
|
||||
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')
|
||||
|
||||
elif store.mode != apprise.PersistentStoreMode.MEMORY:
|
||||
# G
|
||||
path = settings.APPRISE_STORAGE_DIR
|
||||
if lazy:
|
||||
try:
|
||||
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
|
||||
delta = (datetime.now() - modify_date).total_seconds()
|
||||
if delta <= 30.00: # 30s
|
||||
response['persistent_storage'] = True
|
||||
|
||||
except OSError:
|
||||
# No worries... continue with below testing
|
||||
pass
|
||||
|
||||
|
||||
if not (store.set('foo', 'bar') and store.flush()):
|
||||
# No persistent store
|
||||
response['details'].append('STORE_PERMISSION_ISSUE')
|
||||
else:
|
||||
# Toggle our status
|
||||
response['persistent_storage'] = True
|
||||
|
||||
# Clear our test
|
||||
store.clear('foo')
|
||||
|
||||
if not response['details']:
|
||||
response['details'].append('OK')
|
||||
|
||||
|
@ -992,10 +992,16 @@ class NotifyView(View):
|
||||
status=status,
|
||||
)
|
||||
|
||||
# Prepare our keyword arguments (to be passed into an AppriseAsset
|
||||
# object)
|
||||
# Prepare our keyword arguments (to be passed into an AppriseAsset object)
|
||||
kwargs = {
|
||||
# Load our dynamic plugin path
|
||||
'plugin_paths': settings.APPRISE_PLUGIN_PATHS,
|
||||
# Load our persistent storage path
|
||||
'storage_path': settings.APPRISE_STORAGE_DIR,
|
||||
# Our storage URL ID Length
|
||||
'storage_idlen': settings.APPRISE_STORAGE_UID_LENGTH,
|
||||
# Define if we flush to disk as soon as possible or not when required
|
||||
'storage_mode': settings.APPRISE_STORAGE_MODE,
|
||||
}
|
||||
|
||||
if body_format:
|
||||
@ -1353,10 +1359,16 @@ class StatelessNotifyView(View):
|
||||
'error': msg,
|
||||
}, encoder=JSONEncoder, safe=False, status=status)
|
||||
|
||||
# Prepare our keyword arguments (to be passed into an AppriseAsset
|
||||
# object)
|
||||
# Prepare our keyword arguments (to be passed into an AppriseAsset object)
|
||||
kwargs = {
|
||||
# Load our dynamic plugin path
|
||||
'plugin_paths': settings.APPRISE_PLUGIN_PATHS,
|
||||
# Load our persistent storage path
|
||||
'storage_path': settings.APPRISE_STORAGE_DIR,
|
||||
# Our storage URL ID Length
|
||||
'storage_idlen': settings.APPRISE_STORAGE_UID_LENGTH,
|
||||
# Define if we flush to disk as soon as possible or not when required
|
||||
'storage_mode': settings.APPRISE_STORAGE_MODE,
|
||||
}
|
||||
|
||||
if body_format:
|
||||
@ -1576,6 +1588,7 @@ class JsonUrlView(View):
|
||||
# "tags": ["tag1', "tag2", "tag3"],
|
||||
# "urls": [
|
||||
# {
|
||||
# "uid": "efa313ab",
|
||||
# "url": "windows://",
|
||||
# "tags": [],
|
||||
# },
|
||||
@ -1640,6 +1653,7 @@ class JsonUrlView(View):
|
||||
for notification in a_obj.find(tag):
|
||||
# Set Notification
|
||||
response['urls'].append({
|
||||
'id': notification.url_id(),
|
||||
'url': notification.url(privacy=privacy),
|
||||
'tags': notification.tags,
|
||||
})
|
||||
|
Reference in New Issue
Block a user