Support tags GET and POST arg in addition to tag

This commit is contained in:
Chris Caron 2024-01-13 16:26:18 -05:00
parent a80ca0ae60
commit 9a5c19a7e9
3 changed files with 273 additions and 62 deletions

View File

@ -152,6 +152,14 @@ class NotifyForm(forms.Form):
required=False, required=False,
) )
# Allow support for tags keyword in addition to tag; the 'tag' field will always take priority over this
# however adding `tags` gives the user more flexibilty to use either/or keyword
tags = forms.CharField(
label=_('Tags'),
widget=forms.HiddenInput(),
required=False,
)
def clean_type(self): def clean_type(self):
""" """
We just ensure there is a type always set We just ensure there is a type always set

View File

@ -312,7 +312,33 @@ class NotifyTests(SimpleTestCase):
'body': 'test notifiction', 'body': 'test notifiction',
} }
# Reset our count # Reset our mock object
mock_post.reset_mock()
# tags keyword is also supported
response = self.client.post(
'/notify/{}?tags=home'.format(key), form_data)
# Our notification was sent
assert response.status_code == 200
assert mock_post.call_count == 1
# Test our posted data
response = json.loads(mock_post.call_args_list[0][1]['data'])
assert response['title'] == ''
assert response['message'] == form_data['body']
assert response['type'] == apprise.NotifyType.INFO
# Preare our form data (body is actually the minimum requirement)
# All of the rest of the variables can actually be over-ridden
# by the GET Parameter (ONLY if not otherwise identified in the
# payload). The Payload contents of the POST request always take
# priority to eliminate any ambiguity
form_data = {
'body': 'test notifiction',
}
# Reset our mock object
mock_post.reset_mock() mock_post.reset_mock()
# Send our notification by specifying the tag in the parameters # Send our notification by specifying the tag in the parameters
@ -365,6 +391,9 @@ class NotifyTests(SimpleTestCase):
{'config': config}) {'config': config})
assert response.status_code == 200 assert response.status_code == 200
# Reset our mock object
mock_post.reset_mock()
# Preare our form data # Preare our form data
form_data = { form_data = {
'body': 'test notifiction', 'body': 'test notifiction',
@ -384,17 +413,20 @@ class NotifyTests(SimpleTestCase):
assert response.status_code == 424 assert response.status_code == 424
assert mock_post.call_count == 0 assert mock_post.call_count == 0
# Reset our mock object
mock_post.reset_mock()
# Update our tags # Update our tags
form_data['tag'] = ['home', 'summer-home'] form_data['tag'] = ['home', 'summer-home']
# Now let's send our notification by specifying the tag in the # Now let's send our notification by specifying the tag in the
# parameters # parameters
# Send our notification
response = self.client.post( response = self.client.post(
'/notify/{}/'.format(key), content_type='application/json', '/notify/{}/'.format(key), content_type='application/json',
data=form_data) data=form_data)
# Send our notification
# Our notification was sent (as we matched 'home' OR' 'summer-home') # Our notification was sent (as we matched 'home' OR' 'summer-home')
assert response.status_code == 200 assert response.status_code == 200
assert mock_post.call_count == 1 assert mock_post.call_count == 1
@ -405,6 +437,119 @@ class NotifyTests(SimpleTestCase):
assert response['message'] == form_data['body'] assert response['message'] == form_data['body']
assert response['type'] == apprise.NotifyType.INFO assert response['type'] == apprise.NotifyType.INFO
# Reset our mock object
mock_post.reset_mock()
# use the `tags` keyword instead which is also supported
del form_data['tag']
form_data['tags'] = ['home', 'summer-home']
# Now let's send our notification by specifying the tag in the
# parameters
# Send our notification
response = self.client.post(
'/notify/{}/'.format(key), content_type='application/json',
data=form_data)
# Our notification was sent (as we matched 'home' OR' 'summer-home')
assert response.status_code == 200
assert mock_post.call_count == 1
# Test our posted data
response = json.loads(mock_post.call_args_list[0][1]['data'])
assert response['title'] == ''
assert response['message'] == form_data['body']
assert response['type'] == apprise.NotifyType.INFO
# Reset our mock object
mock_post.reset_mock()
# use the `tag` and `tags` keyword causes tag to always take priority
form_data['tag'] = ['invalid']
form_data['tags'] = ['home', 'summer-home']
# Now let's send our notification by specifying the tag in the
# parameters
# Send our notification
response = self.client.post(
'/notify/{}/'.format(key), content_type='application/json',
data=form_data)
# Our notification failed because 'tag' took priority over 'tags' and
# it contains an invalid entry
assert response.status_code == 424
assert mock_post.call_count == 0
# Reset our mock object
mock_post.reset_mock()
# integers or non string not accepted
form_data['tag'] = 42
del form_data['tags']
# Now let's send our notification by specifying the tag in the
# parameters
# Send our notification
response = self.client.post(
'/notify/{}/'.format(key), content_type='application/json',
data=form_data)
# Our notification failed because no tags were loaded
assert response.status_code == 400
assert mock_post.call_count == 0
# Reset our mock object
mock_post.reset_mock()
# integers or non string not accepted
form_data['tag'] = [42, 'valid', 5.4]
# Now let's send our notification by specifying the tag in the
# parameters
# Send our notification
response = self.client.post(
'/notify/{}/'.format(key), content_type='application/json',
data=form_data)
# Our notification makes it through the list check and into the
# Apprise library. It will be at that level that the tags will fail
# validation so there will be no match
assert response.status_code == 424
assert mock_post.call_count == 0
# Reset our mock object
mock_post.reset_mock()
# continued to verify the use of the `tag` and `tags` keyword
# where tag priorities over tags
form_data['tags'] = ['invalid']
form_data['tag'] = ['home', 'summer-home']
# Now let's send our notification by specifying the tag in the
# parameters
# Send our notification
response = self.client.post(
'/notify/{}/'.format(key), content_type='application/json',
data=form_data)
# Our notification was sent (as we matched 'home' OR' 'summer-home')
assert response.status_code == 200
assert mock_post.call_count == 1
# Test our posted data
response = json.loads(mock_post.call_args_list[0][1]['data'])
assert response['title'] == ''
assert response['message'] == form_data['body']
assert response['type'] == apprise.NotifyType.INFO
# Reset our mock object
mock_post.reset_mock()
# Preare our form data (body is actually the minimum requirement) # Preare our form data (body is actually the minimum requirement)
# All of the rest of the variables can actually be over-ridden # All of the rest of the variables can actually be over-ridden
# by the GET Parameter (ONLY if not otherwise identified in the # by the GET Parameter (ONLY if not otherwise identified in the
@ -414,9 +559,6 @@ class NotifyTests(SimpleTestCase):
'body': 'test notifiction', 'body': 'test notifiction',
} }
# Reset our count
mock_post.reset_mock()
# Send our notification by specifying the tag in the parameters # Send our notification by specifying the tag in the parameters
response = self.client.post( response = self.client.post(
'/notify/{}?tag=home&format={}&type={}&title={}&body=ignored' '/notify/{}?tag=home&format={}&type={}&title={}&body=ignored'
@ -721,7 +863,7 @@ class NotifyTests(SimpleTestCase):
assert response.status_code == 200 assert response.status_code == 200
assert mock_notify.call_count == 1 assert mock_notify.call_count == 1
# Reset our count # Reset our mock object
mock_notify.reset_mock() mock_notify.reset_mock()
# Test referencing a key that doesn't exist # Test referencing a key that doesn't exist
@ -798,7 +940,7 @@ class NotifyTests(SimpleTestCase):
assert response.status_code == 500 assert response.status_code == 500
assert mock_notify.call_count == 0 assert mock_notify.call_count == 0
# Reset our count # Reset our mock object
mock_notify.reset_mock() mock_notify.reset_mock()
# Test with invalid format # Test with invalid format
@ -817,7 +959,7 @@ class NotifyTests(SimpleTestCase):
assert response.status_code == 400 assert response.status_code == 400
assert mock_notify.call_count == 0 assert mock_notify.call_count == 0
# Reset our count # Reset our mock object
mock_notify.reset_mock() mock_notify.reset_mock()
# If an empty format is specified, it is accepted and # If an empty format is specified, it is accepted and
@ -837,7 +979,7 @@ class NotifyTests(SimpleTestCase):
assert response.status_code == 200 assert response.status_code == 200
assert mock_notify.call_count == 1 assert mock_notify.call_count == 1
# Reset our count # Reset our mock object
mock_notify.reset_mock() mock_notify.reset_mock()
# Same results for any empty string: # Same results for any empty string:
@ -851,7 +993,7 @@ class NotifyTests(SimpleTestCase):
assert response.status_code == 200 assert response.status_code == 200
assert mock_notify.call_count == 1 assert mock_notify.call_count == 1
# Reset our count # Reset our mock object
mock_notify.reset_mock() mock_notify.reset_mock()
headers = { headers = {
@ -1101,7 +1243,7 @@ class NotifyTests(SimpleTestCase):
# No Recursion value specified # No Recursion value specified
} }
# Reset our count # Reset our mock object
mock_notify.reset_mock() mock_notify.reset_mock()
# Recursion limit reached # Recursion limit reached
@ -1116,7 +1258,7 @@ class NotifyTests(SimpleTestCase):
'HTTP_X-APPRISE-RECURSION-COUNT': str(2), 'HTTP_X-APPRISE-RECURSION-COUNT': str(2),
} }
# Reset our count # Reset our mock object
mock_notify.reset_mock() mock_notify.reset_mock()
# Recursion limit reached # Recursion limit reached
@ -1131,7 +1273,7 @@ class NotifyTests(SimpleTestCase):
'HTTP_X-APPRISE-RECURSION-COUNT': str(-1), 'HTTP_X-APPRISE-RECURSION-COUNT': str(-1),
} }
# Reset our count # Reset our mock object
mock_notify.reset_mock() mock_notify.reset_mock()
# invalid recursion specified # invalid recursion specified
@ -1146,7 +1288,7 @@ class NotifyTests(SimpleTestCase):
'HTTP_X-APPRISE-RECURSION-COUNT': 'invalid', 'HTTP_X-APPRISE-RECURSION-COUNT': 'invalid',
} }
# Reset our count # Reset our mock object
mock_notify.reset_mock() mock_notify.reset_mock()
# invalid recursion specified # invalid recursion specified

View File

@ -370,7 +370,6 @@ class AddView(View):
# Something went very wrong; return 500 # Something went very wrong; return 500
msg = _('An error occured saving configuration.') msg = _('An error occured saving configuration.')
status = ResponseCode.internal_server_error status = ResponseCode.internal_server_error
return HttpResponse(msg, status=status) \ return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({ if not json_response else JsonResponse({
'error': msg, 'error': msg,
@ -487,17 +486,14 @@ class GetView(View):
if settings.APPRISE_CONFIG_LOCK: if settings.APPRISE_CONFIG_LOCK:
# General Access Control # General Access Control
return HttpResponse( msg = _('The site has been configured to deny this request.')
_('The site has been configured to deny this request.'), status = ResponseCode.no_access
status=ResponseCode.no_access, return HttpResponse(msg, status=status) \
) if not json_response else JsonResponse({ if not json_response else JsonResponse(
'error': {'error': msg},
_('The site has been configured to deny this request.') encoder=JSONEncoder,
}, safe=False,
encoder=JSONEncoder, status=status)
safe=False,
status=ResponseCode.no_access,
)
config, format = ConfigCache.get(key) config, format = ConfigCache.get(key)
if config is None: if config is None:
@ -509,15 +505,14 @@ class GetView(View):
# config != None: we simply have no data # config != None: we simply have no data
if format is not None: if format is not None:
# no content to return # no content to return
return HttpResponse( msg = _('There was no configuration found.')
_('There was no configuration found.'), status = ResponseCode.no_content
status=ResponseCode.no_content, return HttpResponse(msg, status=status) \
) if not json_response else JsonResponse({ if not json_response else JsonResponse(
'error': _('There was no configuration found.')}, {'error': msg},
encoder=JSONEncoder, encoder=JSONEncoder,
safe=False, safe=False,
status=ResponseCode.no_content, status=status)
)
# Something went very wrong; return 500 # Something went very wrong; return 500
msg = _('An error occured accessing configuration.') msg = _('An error occured accessing configuration.')
@ -586,15 +581,23 @@ class NotifyView(View):
except (AttributeError, ValueError): except (AttributeError, ValueError):
# could not parse JSON response... # could not parse JSON response...
logger.warning(
'NOTIFY - %s - Invalid JSON Payload provided',
request.META['REMOTE_ADDR'])
return JsonResponse( return JsonResponse(
_('Invalid JSON specified.'), _('Invalid JSON provided.'),
encoder=JSONEncoder, encoder=JSONEncoder,
safe=False, safe=False,
status=ResponseCode.bad_request) status=ResponseCode.bad_request)
if not content: if not content:
# We could not handle the Content-Type # We could not handle the Content-Type
msg = _('The message format is not supported.') logger.warning(
'NOTIFY - %s - Invalid FORM Payload provided',
request.META['REMOTE_ADDR'])
msg = _('Bad FORM Payload provided.')
status = ResponseCode.bad_request status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \ return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({ if not json_response else JsonResponse({
@ -602,7 +605,7 @@ class NotifyView(View):
}, },
encoder=JSONEncoder, encoder=JSONEncoder,
safe=False, safe=False,
status=status, status=status
) )
# Handle Attachments # Handle Attachments
@ -613,6 +616,11 @@ class NotifyView(View):
content.get('attachment'), request.FILES) content.get('attachment'), request.FILES)
except (TypeError, ValueError): except (TypeError, ValueError):
# Invalid entry found in list
logger.warning(
'NOTIFY - %s - Bad attachment specified',
request.META['REMOTE_ADDR'])
return HttpResponse( return HttpResponse(
_('Bad attachment'), _('Bad attachment'),
status=ResponseCode.bad_request) status=ResponseCode.bad_request)
@ -621,26 +629,36 @@ class NotifyView(View):
# Allow 'tag' value to be specified as part of the URL parameters # Allow 'tag' value to be specified as part of the URL parameters
# if not found otherwise defined. # if not found otherwise defined.
# #
if not content.get('tag') and 'tag' in request.GET: tag = content.get('tag', content.get('tags'))
content['tag'] = request.GET['tag'] if not tag:
# Allow GET parameter over-rides
if 'tag' in request.GET:
tag = request.GET['tag']
if content.get('tag'): elif 'tags' in request.GET:
# Validation - Tag Logic: tag = request.GET['tags']
# "TagA" : TagA
# "TagA, TagB" : TagA OR TagB
# "TagA TagB" : TagA AND TagB
# "TagA TagC, TagB" : (TagA AND TagC) OR TagB
# ['TagA', 'TagB'] : TagA OR TagB
# [('TagA', 'TagC'), 'TagB'] : (TagA AND TagC) OR TagB
# [('TagB', 'TagC')] : TagB AND TagC
tag = content.get('tag') # Validation - Tag Logic:
# "TagA" : TagA
# "TagA, TagB" : TagA OR TagB
# "TagA TagB" : TagA AND TagB
# "TagA TagC, TagB" : (TagA AND TagC) OR TagB
# ['TagA', 'TagB'] : TagA OR TagB
# [('TagA', 'TagC'), 'TagB'] : (TagA AND TagC) OR TagB
# [('TagB', 'TagC')] : TagB AND TagC
if tag:
if isinstance(tag, (list, set, tuple)): if isinstance(tag, (list, set, tuple)):
# Assign our tags as they were provided # Assign our tags as they were provided
tags = tag content['tag'] = tag
elif isinstance(tag, str): elif isinstance(tag, str):
if not TAG_VALIDATION_RE.match(content.get('tag')): if not TAG_VALIDATION_RE.match(tag):
# Invalid entry found in list
logger.warning(
'NOTIFY - %s - Ignored invalid tag specified '
'(type %s): %s', request.META['REMOTE_ADDR'],
str(type(tag)), str(tag)[:12])
msg = _('Unsupported characters found in tag definition.') msg = _('Unsupported characters found in tag definition.')
status = ResponseCode.bad_request status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \ return HttpResponse(msg, status=status) \
@ -654,7 +672,7 @@ class NotifyView(View):
# If we get here, our specified tag was valid # If we get here, our specified tag was valid
tags = [] tags = []
for _tag in TAG_DETECT_RE.findall(content.get('tag')): for _tag in TAG_DETECT_RE.findall(tag):
tag = _tag.strip() tag = _tag.strip()
if not tag: if not tag:
continue continue
@ -666,9 +684,25 @@ class NotifyView(View):
else: else:
tags.append(tag) tags.append(tag)
# Update our tag block # Assign our tags
content['tag'] = tags content['tag'] = tags
else: # Could be int, float or some other unsupported type
logger.warning(
'NOTIFY - %s - Ignored invalid tag specified (type %s): '
'%s', request.META['REMOTE_ADDR'],
str(type(tag)), str(tag)[:12])
msg = _('Unsupported characters found in tag definition.')
status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({
'error': msg,
},
encoder=JSONEncoder,
safe=False,
status=status,
)
# #
# Allow 'format' value to be specified as part of the URL # Allow 'format' value to be specified as part of the URL
# parameters if not found otherwise defined. # parameters if not found otherwise defined.
@ -695,20 +729,26 @@ class NotifyView(View):
content.get('type', apprise.NotifyType.INFO) \ content.get('type', apprise.NotifyType.INFO) \
not in apprise.NOTIFY_TYPES: not in apprise.NOTIFY_TYPES:
msg = _('An invalid payload was specified.') logger.warning(
status = ResponseCode.bad_request 'NOTIFY - %s - Payload lacks minimum requirements',
request.META['REMOTE_ADDR'])
return HttpResponse(msg, status=status) \ return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({ if not json_response else JsonResponse({
'error': msg, 'error': _('Payload lacks minimum requirements.'),
}, },
encoder=JSONEncoder, encoder=JSONEncoder,
safe=False, safe=False,
status=status, status=ResponseCode.bad_request,
) )
# Acquire our body format (if identified) # Acquire our body format (if identified)
body_format = content.get('format', apprise.NotifyFormat.TEXT) body_format = content.get('format', apprise.NotifyFormat.TEXT)
if body_format and body_format not in apprise.NOTIFY_FORMATS: if body_format and body_format not in apprise.NOTIFY_FORMATS:
logger.warning(
'NOTIFY - %s - Format parameter contains an unsupported '
'value (%s)', request.META['REMOTE_ADDR'], str(body_format))
msg = _('An invalid body input format was specified.') msg = _('An invalid body input format was specified.')
status = ResponseCode.bad_request status = ResponseCode.bad_request
return HttpResponse(msg, status=status) \ return HttpResponse(msg, status=status) \
@ -732,6 +772,9 @@ class NotifyView(View):
# config != None: we simply have no data # config != None: we simply have no data
if format is not None: if format is not None:
# no content to return # no content to return
logger.debug(
'NOTIFY - %s - Empty configuration found using KEY: %s',
request.META['REMOTE_ADDR'], key)
msg = _('There was no configuration found.') msg = _('There was no configuration found.')
status = ResponseCode.no_content status = ResponseCode.no_content
return HttpResponse(msg, status=status) \ return HttpResponse(msg, status=status) \
@ -746,6 +789,9 @@ class NotifyView(View):
# Something went very wrong; return 500 # Something went very wrong; return 500
msg = _('An error occured accessing configuration.') msg = _('An error occured accessing configuration.')
status = ResponseCode.internal_server_error status = ResponseCode.internal_server_error
logger.error(
'NOTIFY - %s - I/O error accessing configuration '
'using KEY: %s', request.META['REMOTE_ADDR'], key)
return HttpResponse(msg, status=status) \ return HttpResponse(msg, status=status) \
if not json_response else JsonResponse({ if not json_response else JsonResponse({
'error': msg, 'error': msg,
@ -766,15 +812,19 @@ class NotifyView(View):
kwargs['body_format'] = body_format kwargs['body_format'] = body_format
# Acquire our recursion count (if defined) # Acquire our recursion count (if defined)
recursion = request.headers.get('X-Apprise-Recursion-Count', 0)
try: try:
recursion = \ recursion = int(recursion)
int(request.headers.get('X-Apprise-Recursion-Count', 0))
if recursion < 0: if recursion < 0:
# We do not accept negative numbers # We do not accept negative numbers
raise TypeError("Invalid Recursion Value") raise TypeError("Invalid Recursion Value")
if recursion > settings.APPRISE_RECURSION_MAX: if recursion > settings.APPRISE_RECURSION_MAX:
logger.warning(
'NOTIFY - %s - Recursion limit reached (%d > %d)',
request.META['REMOTE_ADDR'], recursion,
settings.APPRISE_RECURSION_MAX)
return HttpResponse( return HttpResponse(
_('The recursion limit has been reached.'), _('The recursion limit has been reached.'),
status=ResponseCode.method_not_accepted) status=ResponseCode.method_not_accepted)
@ -783,6 +833,9 @@ class NotifyView(View):
kwargs['_recursion'] = recursion kwargs['_recursion'] = recursion
except (TypeError, ValueError): except (TypeError, ValueError):
logger.warning(
'NOTIFY - %s - Invalid recursion value (%s) provided',
request.META['REMOTE_ADDR'], str(recursion))
return HttpResponse( return HttpResponse(
_('An invalid recursion value was specified.'), _('An invalid recursion value was specified.'),
status=ResponseCode.bad_request) status=ResponseCode.bad_request)
@ -907,6 +960,10 @@ class NotifyView(View):
# the response to a 424 error code # the response to a 424 error code
msg = _('One or more notification could not be sent.') msg = _('One or more notification could not be sent.')
status = ResponseCode.failed_dependency status = ResponseCode.failed_dependency
logger.warning(
'NOTIFY - %s - One or more notifications not '
'sent%s using KEY: %s', request.META['REMOTE_ADDR'],
'' if not tag else f' (Tags: {tag})', key)
return HttpResponse(response if response else msg, status=status) \ return HttpResponse(response if response else msg, status=status) \
if not json_response else JsonResponse({ if not json_response else JsonResponse({
'error': msg, 'error': msg,
@ -916,6 +973,10 @@ class NotifyView(View):
status=status, status=status,
) )
logger.info(
'NOTIFY - %s - Proccessed%s KEY: %s', request.META['REMOTE_ADDR'],
'' if not tag else f' (Tags: {tag}),', key)
# Return our retrieved content # Return our retrieved content
return HttpResponse( return HttpResponse(
response if response is not None else response if response is not None else