Drop unnecessary file I/O handling + Auto-Detect Configuration Format (#18)

This commit is contained in:
Chris Caron 2020-09-02 17:42:17 -04:00 committed by GitHub
parent 2be187acc4
commit 289fbd8640
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 207 additions and 152 deletions

View File

@ -108,6 +108,10 @@ As an example, the `/json/urls/{KEY}` response might return something like this:
Here is an example using `curl` as to how someone might send a notification to everyone associated with the tag `abc123` (using `/notify/{key}`): Here is an example using `curl` as to how someone might send a notification to everyone associated with the tag `abc123` (using `/notify/{key}`):
```bash ```bash
# Send notification(s) to a {key} defined as 'abc123' # Send notification(s) to a {key} defined as 'abc123'
curl -X POST -d "body=test message" \
http://localhost:8000/notify/abc123
# Here is the same request but using JSON instead:
curl -X POST -d '{"body":"test message"}' \ curl -X POST -d '{"body":"test message"}' \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
http://localhost:8000/notify/abc123 http://localhost:8000/notify/abc123
@ -118,6 +122,10 @@ curl -X POST -d '{"body":"test message"}' \
```bash ```bash
# Send notification(s) to a {key} defined as 'abc123' # Send notification(s) to a {key} defined as 'abc123'
# but only notify the URLs associated with the 'devops' tag # but only notify the URLs associated with the 'devops' tag
curl -X POST -d 'tag=devops&body=test message' \
http://localhost:8000/notify/abc123
# Here is the same request but using JSON instead:
curl -X POST -d '{"tag":"devops", "body":"test message"}' \ curl -X POST -d '{"tag":"devops", "body":"test message"}' \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
http://localhost:8000/notify/abc123 http://localhost:8000/notify/abc123

View File

@ -27,8 +27,12 @@ import apprise
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Auto-Detect Keyword
AUTO_DETECT_CONFIG_KEYWORD = 'auto'
# Define our potential configuration types # Define our potential configuration types
CONFIG_FORMATS = ( CONFIG_FORMATS = (
(AUTO_DETECT_CONFIG_KEYWORD, _('Auto-Detect')),
(apprise.ConfigFormat.TEXT, _('TEXT')), (apprise.ConfigFormat.TEXT, _('TEXT')),
(apprise.ConfigFormat.YAML, _('YAML')), (apprise.ConfigFormat.YAML, _('YAML')),
) )

View File

@ -117,9 +117,11 @@ async function update() {
// perform our status check // perform our status check
let response = await fetch('{% url "get" key %}', { let response = await fetch('{% url "get" key %}', {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
}); });
let result = await response;
if(response.status == 204) if(response.status == 204)
{ {
// no problem; we simply have no content to retrieve // no problem; we simply have no content to retrieve
@ -133,10 +135,22 @@ async function update() {
document.querySelector('.config-overview li a[href="#notify"]') document.querySelector('.config-overview li a[href="#notify"]')
.parentNode.classList.remove('disabled'); .parentNode.classList.remove('disabled');
// get our results
let result = await response.json();
// Set our configuration so it's visible // Set our configuration so it's visible
response.text().then(function (text) { document.querySelector('#id_config').value = result.config;
document.querySelector('#id_config').value = text; // Set our format
}); document.querySelector('#id_format').value = result.format;
// dispatch our event to update our select box
if (typeof(Event) === 'function') {
var event = new Event('change');
} else { // for IE11
var event = document.createEvent('Event');
event.initEvent('change', true, true);
}
document.querySelector('#id_format').dispatchEvent(event);
// Ensure has-config sections are visible // Ensure has-config sections are visible
document.querySelector('.has-config') document.querySelector('.has-config')

View File

@ -25,6 +25,7 @@
from django.test import SimpleTestCase from django.test import SimpleTestCase
from apprise import ConfigFormat from apprise import ConfigFormat
from unittest.mock import patch from unittest.mock import patch
from ..forms import AUTO_DETECT_CONFIG_KEYWORD
import json import json
@ -129,7 +130,7 @@ class AddTests(SimpleTestCase):
# Empty Text Configuration # Empty Text Configuration
config = """ config = """
""" # noqa W293 """ # noqa W293
response = self.client.post( response = self.client.post(
'/add/{}'.format(key), { '/add/{}'.format(key), {
@ -138,7 +139,7 @@ class AddTests(SimpleTestCase):
# Valid Text Configuration # Valid Text Configuration
config = """ config = """
browser,media=notica://VToken browser,media=notica://VTokenC
home=mailto://user:pass@hotmail.com home=mailto://user:pass@hotmail.com
""" """
response = self.client.post( response = self.client.post(
@ -154,6 +155,27 @@ class AddTests(SimpleTestCase):
) )
assert response.status_code == 200 assert response.status_code == 200
# Valid Yaml Configuration
config = """
urls:
- notica://VTokenD:
tag: browser,media
- mailto://user:pass@hotmail.com:
tag: home
"""
response = self.client.post(
'/add/{}'.format(key),
{'format': ConfigFormat.YAML, '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',
)
assert response.status_code == 200
# Test invalid config format # Test invalid config format
response = self.client.post( response = self.client.post(
'/add/{}'.format(key), '/add/{}'.format(key),
@ -162,20 +184,6 @@ class AddTests(SimpleTestCase):
) )
assert response.status_code == 400 assert response.status_code == 400
with patch('tempfile.NamedTemporaryFile') as mock_ntf:
mock_ntf.side_effect = OSError
# we won't be able to write our retrieved configuration
# to disk for processing; we'll get a 500 error
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps(
{'format': ConfigFormat.TEXT, 'config': config}),
content_type='application/json',
)
# internal errors are correctly identified
assert response.status_code == 500
# Test the handling of underlining disk/write exceptions # Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_open: with patch('gzip.open') as mock_open:
mock_open.side_effect = OSError() mock_open.side_effect = OSError()
@ -183,13 +191,89 @@ class AddTests(SimpleTestCase):
response = self.client.post( response = self.client.post(
'/add/{}'.format(key), '/add/{}'.format(key),
data=json.dumps( data=json.dumps(
{'format': ConfigFormat.TEXT, 'config': config}), {'format': ConfigFormat.YAML, 'config': config}),
content_type='application/json', content_type='application/json',
) )
# internal errors are correctly identified # internal errors are correctly identified
assert response.status_code == 500 assert response.status_code == 500
def test_save_auto_detect_config_format(self):
"""
Test adding an configuration and using the autodetect feature
"""
# our key to use
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})
assert response.status_code == 400
# Valid Text Configuration
config = """
browser,media=notica://VTokenA
home=mailto://user:pass@hotmail.com
"""
response = self.client.post(
'/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',
)
assert response.status_code == 200
# Valid Yaml Configuration
config = """
urls:
- notica://VTokenB:
tag: browser,media
- mailto://user:pass@hotmail.com:
tag: home
"""
response = self.client.post(
'/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',
)
assert response.status_code == 200
# Test invalid config format that can not be auto-detected
config = """
42
"""
response = self.client.post(
'/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',
)
assert response.status_code == 400
def test_save_with_bad_input(self): def test_save_with_bad_input(self):
""" """
Test adding with bad input in general Test adding with bad input in general

View File

@ -136,25 +136,6 @@ class JsonUrlsTests(SimpleTestCase):
assert 'tags' in response.json()['urls'][0] assert 'tags' in response.json()['urls'][0]
assert len(response.json()['urls'][0]['tags']) == 2 assert len(response.json()['urls'][0]['tags']) == 2
# 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('tempfile.NamedTemporaryFile') as mock_ntf:
mock_ntf.side_effect = OSError
# Now retrieve our JSON resonse
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()
# has error directive
assert 'error' in response.json()
# entries exist by are empty
assert len(response.json()['tags']) == 0
assert len(response.json()['urls']) == 0
# Verify that the correct Content-Type is set in the header of the # Verify that the correct Content-Type is set in the header of the
# response # response
assert 'Content-Type' in response assert 'Content-Type' in response

View File

@ -212,20 +212,6 @@ class NotifyTests(SimpleTestCase):
'body': 'test message' 'body': 'test message'
} }
with patch('tempfile.NamedTemporaryFile') as mock_ntf:
mock_ntf.side_effect = OSError
# we won't be able to write our retrieved configuration
# to disk for processing; we'll get a 500 error
response = self.client.post(
'/notify/{}'.format(key),
data=json.dumps(json_data),
content_type='application/json',
)
# internal errors are correctly identified
assert response.status_code == 500
assert mock_notify.call_count == 0
# Test the handling of underlining disk/write exceptions # Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_open: with patch('gzip.open') as mock_open:
mock_open.side_effect = OSError() mock_open.side_effect = OSError()

View File

@ -38,8 +38,9 @@ from .forms import AddByUrlForm
from .forms import AddByConfigForm from .forms import AddByConfigForm
from .forms import NotifyForm from .forms import NotifyForm
from .forms import NotifyByUrlForm from .forms import NotifyByUrlForm
from .forms import CONFIG_FORMATS
from .forms import AUTO_DETECT_CONFIG_KEYWORD
import tempfile
import apprise import apprise
import json import json
import re import re
@ -175,7 +176,7 @@ class AddView(View):
elif 'config' in content: elif 'config' in content:
fmt = content.get('format', '').lower() fmt = content.get('format', '').lower()
if fmt not in apprise.CONFIG_FORMATS: if fmt not in [i[0] for i in CONFIG_FORMATS]:
# Format must be one supported by apprise # Format must be one supported by apprise
return HttpResponse( return HttpResponse(
_('The format specified is invalid.'), _('The format specified is invalid.'),
@ -185,41 +186,32 @@ class AddView(View):
# prepare our apprise config object # prepare our apprise config object
ac_obj = apprise.AppriseConfig() ac_obj = apprise.AppriseConfig()
try: if fmt == AUTO_DETECT_CONFIG_KEYWORD:
# Write our file to a temporary file # By setting format to None, it is automatically detected from
with tempfile.NamedTemporaryFile() as f: # within the add_config() call
# Write our content to disk fmt = None
f.write(content['config'].encode())
f.flush()
if not ac_obj.add( # Load our configuration
'file://{}?format={}'.format(f.name, fmt)): if not ac_obj.add_config(content['config'], format=fmt):
# The format could not be detected
# Bad Configuration
return HttpResponse(
_('The configuration specified is invalid.'),
status=ResponseCode.bad_request,
)
# Add our configuration
a_obj.add(ac_obj)
if not len(a_obj):
# No specified URL(s) were loaded due to
# mis-configuration on the caller's part
return HttpResponse(
_('No valid URL(s) were specified.'),
status=ResponseCode.bad_request,
)
except OSError:
# We could not write the temporary file to disk
return HttpResponse( return HttpResponse(
_('The configuration could not be loaded.'), _('The configuration format could not be detected.'),
status=ResponseCode.internal_server_error, status=ResponseCode.bad_request,
) )
if not ConfigCache.put(key, content['config'], fmt=fmt): # Add our configuration
a_obj.add(ac_obj)
if not len(a_obj):
# No specified URL(s) were loaded due to
# mis-configuration on the caller's part
return HttpResponse(
_('No valid URL(s) were specified.'),
status=ResponseCode.bad_request,
)
if not ConfigCache.put(
key, content['config'], fmt=ac_obj[0].config_format):
# Something went very wrong; return 500 # Something went very wrong; return 500
return HttpResponse( return HttpResponse(
_('An error occured saving configuration.'), _('An error occured saving configuration.'),
@ -281,6 +273,9 @@ class GetView(View):
Handle a POST request Handle a POST request
""" """
# Detect the format our response should be in
json_response = MIME_IS_JSON.match(request.content_type) is not None
config, format = ConfigCache.get(key) config, format = ConfigCache.get(key)
if config is None: if config is None:
# The returned value of config and format tell a rather cryptic # The returned value of config and format tell a rather cryptic
@ -294,12 +289,24 @@ class GetView(View):
return HttpResponse( return HttpResponse(
_('There was no configuration found.'), _('There was no configuration found.'),
status=ResponseCode.no_content, status=ResponseCode.no_content,
) if not json_response else JsonResponse({
'error': _('There was no configuration found.')
},
encoder=JSONEncoder,
safe=False,
status=ResponseCode.no_content,
) )
# Something went very wrong; return 500 # Something went very wrong; return 500
return HttpResponse( return HttpResponse(
_('An error occured accessing configuration.'), _('An error occured accessing configuration.'),
status=ResponseCode.internal_server_error, status=ResponseCode.internal_server_error,
) if not json_response else JsonResponse({
'error': _('There was no configuration found.')
},
encoder=JSONEncoder,
safe=False,
status=ResponseCode.internal_server_error,
) )
# Our configuration was retrieved; now our response varies on whether # Our configuration was retrieved; now our response varies on whether
@ -315,6 +322,13 @@ class GetView(View):
config, config,
content_type=content_type, content_type=content_type,
status=ResponseCode.okay, status=ResponseCode.okay,
) if not json_response else JsonResponse({
'format': format,
'config': config,
},
encoder=JSONEncoder,
safe=False,
status=ResponseCode.okay,
) )
@ -391,42 +405,26 @@ class NotifyView(View):
# Create an apprise config object # Create an apprise config object
ac_obj = apprise.AppriseConfig() ac_obj = apprise.AppriseConfig()
try: # Load our configuration
# Write our file to a temporary file containing our configuration ac_obj.add_config(config, format=format)
# so that we can read it back. In the future a change will be to
# Apprise so that we can just directly write the configuration as
# is to the AppriseConfig() object... but for now...
with tempfile.NamedTemporaryFile() as f:
# Write our content to disk
f.write(config.encode())
f.flush()
# Read our configuration back in to our configuration # Add our configuration
ac_obj.add('file://{}?format={}'.format(f.name, format)) a_obj.add(ac_obj)
# Add our configuration # Perform our notification at this point
a_obj.add(ac_obj) result = a_obj.notify(
content.get('body'),
title=content.get('title', ''),
notify_type=content.get('type', apprise.NotifyType.INFO),
tag=content.get('tag'),
)
# Perform our notification at this point if not result:
result = a_obj.notify( # If at least one notification couldn't be sent; change up
content.get('body'), # the response to a 424 error code
title=content.get('title', ''),
notify_type=content.get('type', apprise.NotifyType.INFO),
tag=content.get('tag'),
)
if not result:
# If at least one notification couldn't be sent; change up
# the response to a 424 error code
return HttpResponse(
_('One or more notification could not be sent.'),
status=ResponseCode.failed_dependency)
except OSError:
# We could not write the temporary file to disk
return HttpResponse( return HttpResponse(
_('The configuration could not be loaded.'), _('One or more notification could not be sent.'),
status=ResponseCode.internal_server_error) status=ResponseCode.failed_dependency)
# Return our retrieved content # Return our retrieved content
return HttpResponse( return HttpResponse(
@ -582,41 +580,21 @@ class JsonUrlView(View):
# Create an apprise config object # Create an apprise config object
ac_obj = apprise.AppriseConfig() ac_obj = apprise.AppriseConfig()
try: # Load our configuration
# Write our file to a temporary file containing our configuration ac_obj.add_config(config, format=format)
# so that we can read it back. In the future a change will be to
# Apprise so that we can just directly write the configuration as
# is to the AppriseConfig() object... but for now...
with tempfile.NamedTemporaryFile() as f:
# Write our content to disk
f.write(config.encode())
f.flush()
# Read our configuration back in to our configuration # Add our configuration
ac_obj.add('file://{}?format={}'.format(f.name, format)) a_obj.add(ac_obj)
# Add our configuration for notification in a_obj:
a_obj.add(ac_obj) # Set Notification
response['urls'].append({
'url': notification.url(privacy=privacy),
'tags': notification.tags,
})
for notification in a_obj: # Store Tags
# Set Notification response['tags'] |= notification.tags
response['urls'].append({
'url': notification.url(privacy=privacy),
'tags': notification.tags,
})
# Store Tags
response['tags'] |= notification.tags
except OSError:
# We could not write the temporary file to disk
response['error'] = _('The configuration could not be loaded.'),
return JsonResponse(
response,
encoder=JSONEncoder,
safe=False,
status=ResponseCode.internal_server_error,
)
# Return our retrieved content # Return our retrieved content
return JsonResponse( return JsonResponse(

View File

@ -1,2 +1,2 @@
django django
apprise apprise >= 0.8.8