Persistent Storage Support (Apprise v1.9.0+) Added

This commit is contained in:
Chris Caron
2024-09-02 22:11:30 -04:00
parent e50b2dbad9
commit f87f4c3471
15 changed files with 367 additions and 34 deletions

View File

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

View File

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

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

View File

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

View 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

View File

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

View File

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