From 68fd580b842dc5702d2da2bad6ffe40422d84848 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 13 Jan 2024 22:11:34 -0500 Subject: [PATCH] Support `tags` keyword and added logging (#163) --- apprise_api/api/forms.py | 8 ++ apprise_api/api/tests/test_notify.py | 168 ++++++++++++++++++++++++--- apprise_api/api/views.py | 159 +++++++++++++++++-------- 3 files changed, 273 insertions(+), 62 deletions(-) diff --git a/apprise_api/api/forms.py b/apprise_api/api/forms.py index 6a4d868..62ef47d 100644 --- a/apprise_api/api/forms.py +++ b/apprise_api/api/forms.py @@ -152,6 +152,14 @@ class NotifyForm(forms.Form): 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): """ We just ensure there is a type always set diff --git a/apprise_api/api/tests/test_notify.py b/apprise_api/api/tests/test_notify.py index 3c18740..76d4e8a 100644 --- a/apprise_api/api/tests/test_notify.py +++ b/apprise_api/api/tests/test_notify.py @@ -312,7 +312,33 @@ def test_notify_with_tags(self, mock_post): '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() # Send our notification by specifying the tag in the parameters @@ -365,6 +391,9 @@ def test_notify_with_tags_via_apprise(self, mock_post): {'config': config}) assert response.status_code == 200 + # Reset our mock object + mock_post.reset_mock() + # Preare our form data form_data = { 'body': 'test notifiction', @@ -384,16 +413,44 @@ def test_notify_with_tags_via_apprise(self, mock_post): assert response.status_code == 424 assert mock_post.call_count == 0 + # Reset our mock object + mock_post.reset_mock() + # Update our tags 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() + + # 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 @@ -405,6 +462,94 @@ def test_notify_with_tags_via_apprise(self, mock_post): 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) # All of the rest of the variables can actually be over-ridden # by the GET Parameter (ONLY if not otherwise identified in the @@ -414,9 +559,6 @@ def test_notify_with_tags_via_apprise(self, mock_post): 'body': 'test notifiction', } - # Reset our count - mock_post.reset_mock() - # Send our notification by specifying the tag in the parameters response = self.client.post( '/notify/{}?tag=home&format={}&type={}&title={}&body=ignored' @@ -721,7 +863,7 @@ def test_notify_by_loaded_urls_with_json(self, mock_notify): assert response.status_code == 200 assert mock_notify.call_count == 1 - # Reset our count + # Reset our mock object mock_notify.reset_mock() # Test referencing a key that doesn't exist @@ -798,7 +940,7 @@ def test_notify_by_loaded_urls_with_json(self, mock_notify): assert response.status_code == 500 assert mock_notify.call_count == 0 - # Reset our count + # Reset our mock object mock_notify.reset_mock() # Test with invalid format @@ -817,7 +959,7 @@ def test_notify_by_loaded_urls_with_json(self, mock_notify): assert response.status_code == 400 assert mock_notify.call_count == 0 - # Reset our count + # Reset our mock object mock_notify.reset_mock() # If an empty format is specified, it is accepted and @@ -837,7 +979,7 @@ def test_notify_by_loaded_urls_with_json(self, mock_notify): assert response.status_code == 200 assert mock_notify.call_count == 1 - # Reset our count + # Reset our mock object mock_notify.reset_mock() # Same results for any empty string: @@ -851,7 +993,7 @@ def test_notify_by_loaded_urls_with_json(self, mock_notify): assert response.status_code == 200 assert mock_notify.call_count == 1 - # Reset our count + # Reset our mock object mock_notify.reset_mock() headers = { @@ -1101,7 +1243,7 @@ def test_stateful_notify_recursion(self, mock_notify): # No Recursion value specified } - # Reset our count + # Reset our mock object mock_notify.reset_mock() # Recursion limit reached @@ -1116,7 +1258,7 @@ def test_stateful_notify_recursion(self, mock_notify): 'HTTP_X-APPRISE-RECURSION-COUNT': str(2), } - # Reset our count + # Reset our mock object mock_notify.reset_mock() # Recursion limit reached @@ -1131,7 +1273,7 @@ def test_stateful_notify_recursion(self, mock_notify): 'HTTP_X-APPRISE-RECURSION-COUNT': str(-1), } - # Reset our count + # Reset our mock object mock_notify.reset_mock() # invalid recursion specified @@ -1146,7 +1288,7 @@ def test_stateful_notify_recursion(self, mock_notify): 'HTTP_X-APPRISE-RECURSION-COUNT': 'invalid', } - # Reset our count + # Reset our mock object mock_notify.reset_mock() # invalid recursion specified diff --git a/apprise_api/api/views.py b/apprise_api/api/views.py index da5e7ab..ef5e203 100644 --- a/apprise_api/api/views.py +++ b/apprise_api/api/views.py @@ -370,7 +370,6 @@ def post(self, request, key): # Something went very wrong; return 500 msg = _('An error occured saving configuration.') status = ResponseCode.internal_server_error - return HttpResponse(msg, status=status) \ if not json_response else JsonResponse({ 'error': msg, @@ -487,17 +486,14 @@ def post(self, request, key): if settings.APPRISE_CONFIG_LOCK: # General Access Control - return HttpResponse( - _('The site has been configured to deny this request.'), - status=ResponseCode.no_access, - ) if not json_response else JsonResponse({ - 'error': - _('The site has been configured to deny this request.') - }, - encoder=JSONEncoder, - safe=False, - status=ResponseCode.no_access, - ) + msg = _('The site has been configured to deny this request.') + status = ResponseCode.no_access + return HttpResponse(msg, status=status) \ + if not json_response else JsonResponse( + {'error': msg}, + encoder=JSONEncoder, + safe=False, + status=status) config, format = ConfigCache.get(key) if config is None: @@ -509,15 +505,14 @@ def post(self, request, key): # config != None: we simply have no data if format is not None: # no content to return - return HttpResponse( - _('There was no configuration found.'), - status=ResponseCode.no_content, - ) if not json_response else JsonResponse({ - 'error': _('There was no configuration found.')}, - encoder=JSONEncoder, - safe=False, - status=ResponseCode.no_content, - ) + msg = _('There was no configuration found.') + status = ResponseCode.no_content + return HttpResponse(msg, status=status) \ + if not json_response else JsonResponse( + {'error': msg}, + encoder=JSONEncoder, + safe=False, + status=status) # Something went very wrong; return 500 msg = _('An error occured accessing configuration.') @@ -586,15 +581,23 @@ def post(self, request, key): except (AttributeError, ValueError): # could not parse JSON response... + logger.warning( + 'NOTIFY - %s - Invalid JSON Payload provided', + request.META['REMOTE_ADDR']) + return JsonResponse( - _('Invalid JSON specified.'), + _('Invalid JSON provided.'), encoder=JSONEncoder, safe=False, status=ResponseCode.bad_request) if not content: # 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 return HttpResponse(msg, status=status) \ if not json_response else JsonResponse({ @@ -602,7 +605,7 @@ def post(self, request, key): }, encoder=JSONEncoder, safe=False, - status=status, + status=status ) # Handle Attachments @@ -613,6 +616,11 @@ def post(self, request, key): content.get('attachment'), request.FILES) except (TypeError, ValueError): + # Invalid entry found in list + logger.warning( + 'NOTIFY - %s - Bad attachment specified', + request.META['REMOTE_ADDR']) + return HttpResponse( _('Bad attachment'), status=ResponseCode.bad_request) @@ -621,26 +629,36 @@ def post(self, request, key): # Allow 'tag' value to be specified as part of the URL parameters # if not found otherwise defined. # - if not content.get('tag') and 'tag' in request.GET: - content['tag'] = request.GET['tag'] - - if 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 - - tag = content.get('tag') + tag = content.get('tag', content.get('tags')) + if not tag: + # Allow GET parameter over-rides + if 'tag' in request.GET: + tag = request.GET['tag'] + + elif 'tags' in request.GET: + tag = request.GET['tags'] + + # 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)): # Assign our tags as they were provided - tags = tag + content['tag'] = tag 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.') status = ResponseCode.bad_request return HttpResponse(msg, status=status) \ @@ -654,7 +672,7 @@ def post(self, request, key): # If we get here, our specified tag was valid tags = [] - for _tag in TAG_DETECT_RE.findall(content.get('tag')): + for _tag in TAG_DETECT_RE.findall(tag): tag = _tag.strip() if not tag: continue @@ -666,9 +684,25 @@ def post(self, request, key): else: tags.append(tag) - # Update our tag block - content['tag'] = tags + # Assign our 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 # parameters if not found otherwise defined. @@ -695,20 +729,26 @@ def post(self, request, key): content.get('type', apprise.NotifyType.INFO) \ not in apprise.NOTIFY_TYPES: - msg = _('An invalid payload was specified.') - status = ResponseCode.bad_request + logger.warning( + 'NOTIFY - %s - Payload lacks minimum requirements', + request.META['REMOTE_ADDR']) + return HttpResponse(msg, status=status) \ if not json_response else JsonResponse({ - 'error': msg, + 'error': _('Payload lacks minimum requirements.'), }, encoder=JSONEncoder, safe=False, - status=status, + status=ResponseCode.bad_request, ) # Acquire our body format (if identified) body_format = content.get('format', apprise.NotifyFormat.TEXT) 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.') status = ResponseCode.bad_request return HttpResponse(msg, status=status) \ @@ -732,6 +772,9 @@ def post(self, request, key): # config != None: we simply have no data if format is not None: # 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.') status = ResponseCode.no_content return HttpResponse(msg, status=status) \ @@ -746,6 +789,9 @@ def post(self, request, key): # Something went very wrong; return 500 msg = _('An error occured accessing configuration.') 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) \ if not json_response else JsonResponse({ 'error': msg, @@ -766,15 +812,19 @@ def post(self, request, key): kwargs['body_format'] = body_format # Acquire our recursion count (if defined) + recursion = request.headers.get('X-Apprise-Recursion-Count', 0) try: - recursion = \ - int(request.headers.get('X-Apprise-Recursion-Count', 0)) + recursion = int(recursion) if recursion < 0: # We do not accept negative numbers raise TypeError("Invalid Recursion Value") 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( _('The recursion limit has been reached.'), status=ResponseCode.method_not_accepted) @@ -783,6 +833,9 @@ def post(self, request, key): kwargs['_recursion'] = recursion except (TypeError, ValueError): + logger.warning( + 'NOTIFY - %s - Invalid recursion value (%s) provided', + request.META['REMOTE_ADDR'], str(recursion)) return HttpResponse( _('An invalid recursion value was specified.'), status=ResponseCode.bad_request) @@ -907,6 +960,10 @@ def post(self, request, key): # the response to a 424 error code msg = _('One or more notification could not be sent.') 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) \ if not json_response else JsonResponse({ 'error': msg, @@ -916,6 +973,10 @@ def post(self, request, key): 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 HttpResponse( response if response is not None else