From 38d1b2d3c0be596f4445315d4b27cf37f0966a32 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 09:16:30 +0100 Subject: [PATCH 01/20] chg(apprise): use constants so it's easy to reuse assets values, pass them directly into the constructor of AppriseAsset Added some basic tests to validate those changes --- changedetectionio/apprise_asset.py | 22 +++++++++------- .../tests/apprise/test_apprise_asset.py | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 changedetectionio/tests/apprise/test_apprise_asset.py diff --git a/changedetectionio/apprise_asset.py b/changedetectionio/apprise_asset.py index 949569495f2..2f16c15d466 100644 --- a/changedetectionio/apprise_asset.py +++ b/changedetectionio/apprise_asset.py @@ -1,12 +1,16 @@ -from changedetectionio import apprise_plugin -import apprise +from apprise import AppriseAsset -# Create our AppriseAsset and populate it with some of our new values: +# Refer to: # https://github.com/caronc/apprise/wiki/Development_API#the-apprise-asset-object -asset = apprise.AppriseAsset( - image_url_logo='https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png' -) -asset.app_id = "changedetection.io" -asset.app_desc = "ChangeDetection.io best and simplest website monitoring and change detection" -asset.app_url = "https://changedetection.io" +APPRISE_APP_ID = "changedetection.io" +APPRISE_APP_DESC = "ChangeDetection.io best and simplest website monitoring and change detection" +APPRISE_APP_URL = "https://changedetection.io" +APPRISE_AVATAR_URL = "https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png" + +asset = AppriseAsset( + app_id=APPRISE_APP_ID, + app_desc=APPRISE_APP_DESC, + app_url=APPRISE_APP_URL, + image_url_logo=APPRISE_AVATAR_URL, +) diff --git a/changedetectionio/tests/apprise/test_apprise_asset.py b/changedetectionio/tests/apprise/test_apprise_asset.py new file mode 100644 index 00000000000..a3728bf9c48 --- /dev/null +++ b/changedetectionio/tests/apprise/test_apprise_asset.py @@ -0,0 +1,25 @@ +import pytest + +from apprise import AppriseAsset + +from changedetectionio.apprise_asset import ( + APPRISE_APP_DESC, + APPRISE_APP_ID, + APPRISE_AVATAR_URL, + APPRISE_APP_URL, +) + + +@pytest.fixture(scope="function") +def apprise_asset() -> AppriseAsset: + from changedetectionio.apprise_asset import asset + + return asset + + +def test_apprise_asset_init(apprise_asset: AppriseAsset): + assert isinstance(apprise_asset, AppriseAsset) + assert apprise_asset.app_id == APPRISE_APP_ID + assert apprise_asset.app_desc == APPRISE_APP_DESC + assert apprise_asset.app_url == APPRISE_APP_URL + assert apprise_asset.image_url_logo == APPRISE_AVATAR_URL From a02391bf0792e0e1716053442f952df9b75787a4 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 09:26:46 +0100 Subject: [PATCH 02/20] chore(apprise); renamed asset to apprise_asset to better use in import --- changedetectionio/apprise_asset.py | 2 +- changedetectionio/blueprint/ui/notification.py | 4 ++-- changedetectionio/forms.py | 2 +- changedetectionio/notification.py | 6 +++--- changedetectionio/tests/apprise/test_apprise_asset.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/changedetectionio/apprise_asset.py b/changedetectionio/apprise_asset.py index 2f16c15d466..4e6392efc0e 100644 --- a/changedetectionio/apprise_asset.py +++ b/changedetectionio/apprise_asset.py @@ -8,7 +8,7 @@ APPRISE_APP_URL = "https://changedetection.io" APPRISE_AVATAR_URL = "https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png" -asset = AppriseAsset( +apprise_asset = AppriseAsset( app_id=APPRISE_APP_ID, app_desc=APPRISE_APP_DESC, app_url=APPRISE_APP_URL, diff --git a/changedetectionio/blueprint/ui/notification.py b/changedetectionio/blueprint/ui/notification.py index a946917b492..4334d6d8fd7 100644 --- a/changedetectionio/blueprint/ui/notification.py +++ b/changedetectionio/blueprint/ui/notification.py @@ -17,8 +17,8 @@ def ajax_callback_send_notification_test(watch_uuid=None): # Watch_uuid could be unset in the case it`s used in tag editor, global settings import apprise - from changedetectionio.apprise_asset import asset - apobj = apprise.Apprise(asset=asset) + from changedetectionio.apprise_asset import apprise_asset + apobj = apprise.Apprise(asset=apprise_asset) # so that the custom endpoints are registered from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 3fd199bb5c9..96303047e99 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -308,7 +308,7 @@ def __call__(self, form, field): apobj = apprise.Apprise() # so that the custom endpoints are registered - from .apprise_asset import asset + from .apprise_asset import apprise_asset for server_url in field.data: url = server_url.strip() diff --git a/changedetectionio/notification.py b/changedetectionio/notification.py index 7eed328d129..b2d33fe0f6b 100644 --- a/changedetectionio/notification.py +++ b/changedetectionio/notification.py @@ -66,12 +66,12 @@ def process_notification(n_object, datastore): # raise it as an exception sent_objs = [] - from .apprise_asset import asset + from .apprise_asset import apprise_asset if 'as_async' in n_object: - asset.async_mode = n_object.get('as_async') + apprise_asset.async_mode = n_object.get('as_async') - apobj = apprise.Apprise(debug=True, asset=asset) + apobj = apprise.Apprise(debug=True, asset=apprise_asset) if not n_object.get('notification_urls'): return None diff --git a/changedetectionio/tests/apprise/test_apprise_asset.py b/changedetectionio/tests/apprise/test_apprise_asset.py index a3728bf9c48..68f726fc592 100644 --- a/changedetectionio/tests/apprise/test_apprise_asset.py +++ b/changedetectionio/tests/apprise/test_apprise_asset.py @@ -12,9 +12,9 @@ @pytest.fixture(scope="function") def apprise_asset() -> AppriseAsset: - from changedetectionio.apprise_asset import asset + from changedetectionio.apprise_asset import apprise_asset - return asset + return apprise_asset def test_apprise_asset_init(apprise_asset: AppriseAsset): From efe0fe25b207e5dedf67c0a72e22de0a26f55574 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 09:35:01 +0100 Subject: [PATCH 03/20] chg(notifications): use of constants for avatar url --- changedetectionio/notification.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changedetectionio/notification.py b/changedetectionio/notification.py index b2d33fe0f6b..899e1e136e5 100644 --- a/changedetectionio/notification.py +++ b/changedetectionio/notification.py @@ -4,6 +4,7 @@ import apprise from loguru import logger +from changedetectionio.apprise_asset import APPRISE_AVATAR_URL valid_tokens = { 'base_url': '', @@ -112,7 +113,7 @@ def process_notification(n_object, datastore): and not url.startswith('get') \ and not url.startswith('delete') \ and not url.startswith('put'): - url += k + 'avatar_url=https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/changedetectionio/static/images/avatar-256x256.png' + url += k + f"avatar_url={APPRISE_AVATAR_URL}" if url.startswith('tgram://'): # Telegram only supports a limit subset of HTML, remove the '
' we place in. From 7f037a441a326f07d27665217435746d44568108 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 10:33:52 +0100 Subject: [PATCH 04/20] add(apprise): extended support for `patch` and `head` HTTP methods --- changedetectionio/apprise_plugin/__init__.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index dc434673333..b0415018e7d 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -1,18 +1,27 @@ -# include the decorator from apprise.decorators import notify from loguru import logger from requests.structures import CaseInsensitiveDict -@notify(on="delete") -@notify(on="deletes") @notify(on="get") @notify(on="gets") @notify(on="post") @notify(on="posts") @notify(on="put") @notify(on="puts") -def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs): +@notify(on="delete") +@notify(on="deletes") +@notify(on="patch") +@notify(on="patchs") +@notify(on="head") +@notify(on="heads") +def apprise_custom_api_call_wrapper( + body: str, + title: str, + notify_type: str, + *args, + **kwargs, +) -> bool: import requests import json import re From 2f885b30cad5eb327959b15d7d3a174008395de7 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 10:37:53 +0100 Subject: [PATCH 05/20] chg(apprise): removed unused function params and directly use of meta param --- changedetectionio/apprise_plugin/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index b0415018e7d..e2129f2ea5c 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -17,8 +17,7 @@ @notify(on="heads") def apprise_custom_api_call_wrapper( body: str, - title: str, - notify_type: str, + meta: dict, *args, **kwargs, ) -> bool: @@ -29,8 +28,8 @@ def apprise_custom_api_call_wrapper( from urllib.parse import unquote_plus from apprise.utils.parse import parse_url as apprise_parse_url - url = kwargs['meta'].get('url') - schema = kwargs['meta'].get('schema').lower().strip() + url = meta.get("url") + schema = meta.get("schema").lower().strip() # Choose POST, GET etc from requests method = re.sub(rf's$', '', schema) From f0658a0e4288d1f5d35ffa56d354c41400ae86f5 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 10:42:45 +0100 Subject: [PATCH 06/20] chg(apprise): use `requests.request` intead of get the method alias using the http verb --- changedetectionio/apprise_plugin/__init__.py | 30 +++++++++----------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index e2129f2ea5c..5bb53f71d40 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -1,4 +1,10 @@ +import json +import re +from urllib.parse import unquote_plus + +import requests from apprise.decorators import notify +from apprise.utils.parse import parse_url as apprise_parse_url from loguru import logger from requests.structures import CaseInsensitiveDict @@ -21,19 +27,9 @@ def apprise_custom_api_call_wrapper( *args, **kwargs, ) -> bool: - import requests - import json - import re - - from urllib.parse import unquote_plus - from apprise.utils.parse import parse_url as apprise_parse_url - url = meta.get("url") schema = meta.get("schema").lower().strip() - - # Choose POST, GET etc from requests - method = re.sub(rf's$', '', schema) - requests_method = getattr(requests, method) + method = re.sub(r"s$", "", schema).upper() params = CaseInsensitiveDict({}) # Added to requests auth = None @@ -80,11 +76,13 @@ def apprise_custom_api_call_wrapper( status_str = '' try: - r = requests_method(url, - auth=auth, - data=body.encode('utf-8') if type(body) is str else body, - headers=headers, - params=params + r = requests.request( + method=method, + url=url, + auth=auth, + data=body.encode("utf-8") if type(body) is str else body, + headers=headers, + params=params, ) if not (200 <= r.status_code < 300): From 38a289902dea9311154bec4866ebad62411b2fdc Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 10:48:55 +0100 Subject: [PATCH 07/20] chore(apprise): type hinting and renaming --- changedetectionio/apprise_plugin/__init__.py | 26 ++++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index 5bb53f71d40..19c16fbcf58 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -27,35 +27,35 @@ def apprise_custom_api_call_wrapper( *args, **kwargs, ) -> bool: - url = meta.get("url") - schema = meta.get("schema").lower().strip() - method = re.sub(r"s$", "", schema).upper() + url: str = meta.get("url") + schema: str = meta.get("schema").lower().strip() + method: str = re.sub(r"s$", "", schema).upper() params = CaseInsensitiveDict({}) # Added to requests auth = None has_error = False # Convert /foobar?+some-header=hello to proper header dictionary - results = apprise_parse_url(url) + parsed_url: dict[str, str | dict | None] = apprise_parse_url(url) # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them headers = CaseInsensitiveDict({unquote_plus(x): unquote_plus(y) - for x, y in results['qsd+'].items()}) + for x, y in parsed_url['qsd+'].items()}) # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise # but here we are making straight requests, so we need todo convert this against apprise's logic - for k, v in results['qsd'].items(): - if not k.strip('+-') in results['qsd+'].keys(): + for k, v in parsed_url['qsd'].items(): + if not k.strip('+-') in parsed_url['qsd+'].keys(): params[unquote_plus(k)] = unquote_plus(v) # Determine Authentication auth = '' - if results.get('user') and results.get('password'): - auth = (unquote_plus(results.get('user')), unquote_plus(results.get('user'))) - elif results.get('user'): - auth = (unquote_plus(results.get('user'))) + if parsed_url.get('user') and parsed_url.get('password'): + auth = (unquote_plus(parsed_url.get('user')), unquote_plus(parsed_url.get('user'))) + elif parsed_url.get('user'): + auth = (unquote_plus(parsed_url.get('user'))) # If it smells like it could be JSON and no content-type was already set, offer a default content type. if body and '{' in body[:100] and not headers.get('Content-Type'): @@ -70,9 +70,9 @@ def apprise_custom_api_call_wrapper( # POSTS -> HTTPS etc if schema.lower().endswith('s'): - url = re.sub(rf'^{schema}', 'https', results.get('url')) + url = re.sub(rf'^{schema}', 'https', parsed_url.get('url')) else: - url = re.sub(rf'^{schema}', 'http', results.get('url')) + url = re.sub(rf'^{schema}', 'http', parsed_url.get('url')) status_str = '' try: From 05fd009cde430253ed568fc6e759c20cbb3ccb33 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 10:51:13 +0100 Subject: [PATCH 08/20] fix(apprise): the user password was not stored into the auth tuple due to a type, the "user" component was inserted twice into the auth tuple --- changedetectionio/apprise_plugin/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index 19c16fbcf58..ea54844bc4c 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -50,10 +50,9 @@ def apprise_custom_api_call_wrapper( if not k.strip('+-') in parsed_url['qsd+'].keys(): params[unquote_plus(k)] = unquote_plus(v) - # Determine Authentication - auth = '' + auth = "" if parsed_url.get('user') and parsed_url.get('password'): - auth = (unquote_plus(parsed_url.get('user')), unquote_plus(parsed_url.get('user'))) + auth = (unquote_plus(parsed_url.get('user')), unquote_plus(parsed_url.get('password'))) elif parsed_url.get('user'): auth = (unquote_plus(parsed_url.get('user'))) From 3214801a204867fd7bdc6c0b87772e9e9d4f4d4a Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 10:54:42 +0100 Subject: [PATCH 09/20] chore(apprise): increased readability and optimized dict access in authentication process chg(apprise): create headers and params dict in place --- changedetectionio/apprise_plugin/__init__.py | 44 ++++++++++++-------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index ea54844bc4c..3efb2afa025 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -9,6 +9,19 @@ from requests.structures import CaseInsensitiveDict +def _get_auth(parsed_url: dict) -> str | tuple[str, str]: + auth_user: str | None = parsed_url.get("user") + auth_password: str | None = parsed_url.get("password") + + if auth_user is not None and auth_password is not None: + return (unquote_plus(auth_user), unquote_plus(auth_password)) + + if auth_user is not None: + return unquote_plus(auth_user) + + return "" + + @notify(on="get") @notify(on="gets") @notify(on="post") @@ -29,32 +42,26 @@ def apprise_custom_api_call_wrapper( ) -> bool: url: str = meta.get("url") schema: str = meta.get("schema").lower().strip() - method: str = re.sub(r"s$", "", schema).upper() - - params = CaseInsensitiveDict({}) # Added to requests - auth = None - has_error = False # Convert /foobar?+some-header=hello to proper header dictionary parsed_url: dict[str, str | dict | None] = apprise_parse_url(url) - # Add our headers that the user can potentially over-ride if they wish - # to to our returned result set and tidy entries by unquoting them - headers = CaseInsensitiveDict({unquote_plus(x): unquote_plus(y) - for x, y in parsed_url['qsd+'].items()}) + headers = CaseInsensitiveDict( + {unquote_plus(k): unquote_plus(v) for k, v in parsed_url["qsd+"].items()} + ) # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise # but here we are making straight requests, so we need todo convert this against apprise's logic - for k, v in parsed_url['qsd'].items(): - if not k.strip('+-') in parsed_url['qsd+'].keys(): - params[unquote_plus(k)] = unquote_plus(v) + params = CaseInsensitiveDict( + { + unquote_plus(k): unquote_plus(v) + for k, v in parsed_url["qsd"].items() + if k.strip("+-") not in parsed_url["qsd+"] + } + ) - auth = "" - if parsed_url.get('user') and parsed_url.get('password'): - auth = (unquote_plus(parsed_url.get('user')), unquote_plus(parsed_url.get('password'))) - elif parsed_url.get('user'): - auth = (unquote_plus(parsed_url.get('user'))) + auth = _get_auth(parsed_url=parsed_url) # If it smells like it could be JSON and no content-type was already set, offer a default content type. if body and '{' in body[:100] and not headers.get('Content-Type'): @@ -74,6 +81,9 @@ def apprise_custom_api_call_wrapper( url = re.sub(rf'^{schema}', 'http', parsed_url.get('url')) status_str = '' + has_error = False + method: str = re.sub(r"s$", "", schema).upper() + try: r = requests.request( method=method, From ad41a9bcf0f6d6b31d030ece4bb4b92c4028d153 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 11:18:30 +0100 Subject: [PATCH 10/20] chg(apprise): more readble content type guess chore(apprise): more readable schema substitution chore(apprise): renaiming --- changedetectionio/apprise_plugin/__init__.py | 29 ++++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index 3efb2afa025..105cfb56fd7 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -10,14 +10,14 @@ def _get_auth(parsed_url: dict) -> str | tuple[str, str]: - auth_user: str | None = parsed_url.get("user") - auth_password: str | None = parsed_url.get("password") + user: str | None = parsed_url.get("user") + password: str | None = parsed_url.get("password") - if auth_user is not None and auth_password is not None: - return (unquote_plus(auth_user), unquote_plus(auth_password)) + if user is not None and password is not None: + return (unquote_plus(user), unquote_plus(password)) - if auth_user is not None: - return unquote_plus(auth_user) + if user is not None: + return unquote_plus(user) return "" @@ -63,22 +63,15 @@ def apprise_custom_api_call_wrapper( auth = _get_auth(parsed_url=parsed_url) - # If it smells like it could be JSON and no content-type was already set, offer a default content type. - if body and '{' in body[:100] and not headers.get('Content-Type'): - json_header = 'application/json; charset=utf-8' + # If Content-Type is not specified, guess if it's a JSON body + if headers.get("Content-Type") is None: try: - # Try if it's JSON json.loads(body) - headers['Content-Type'] = json_header - except ValueError as e: - logger.warning(f"Could not automatically add '{json_header}' header to the notification because the document failed to parse as JSON: {e}") + headers['Content-Type'] = 'application/json; charset=utf-8' + except ValueError: pass - # POSTS -> HTTPS etc - if schema.lower().endswith('s'): - url = re.sub(rf'^{schema}', 'https', parsed_url.get('url')) - else: - url = re.sub(rf'^{schema}', 'http', parsed_url.get('url')) + url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url")) status_str = '' has_error = False From a9b4150db157df0301b8c7c4e138b533c7e1e866 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 17:53:55 +0100 Subject: [PATCH 11/20] fix(apprise): typo in custom api wrapper params --- changedetectionio/apprise_plugin/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index 105cfb56fd7..e3e8a3db1d6 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -36,6 +36,8 @@ def _get_auth(parsed_url: dict) -> str | tuple[str, str]: @notify(on="heads") def apprise_custom_api_call_wrapper( body: str, + title: str, + notify_type: str, meta: dict, *args, **kwargs, From 77e07cb1baa1623de898b347ed05b5617e1642c0 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 18:37:25 +0100 Subject: [PATCH 12/20] refactor(apprise): moved headers and params parsing logic into separate functions --- changedetectionio/apprise_plugin/__init__.py | 66 +++++++++++--------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index e3e8a3db1d6..c43b0f7aabe 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -21,6 +21,36 @@ def _get_auth(parsed_url: dict) -> str | tuple[str, str]: return "" +def _get_headers(parsed_url: dict, body: str) -> CaseInsensitiveDict: + headers = CaseInsensitiveDict( + {unquote_plus(k).capitalize(): unquote_plus(v) + for k, v in parsed_url["qsd+"].items()} + ) + + # If Content-Type is not specified, guess if the body is a valid JSON + if headers.get("Content-Type") is None: + try: + json.loads(body) + headers['Content-Type'] = 'application/json; charset=utf-8' + except Exception: + pass + + return headers + + +def _get_params(parsed_url: dict) -> CaseInsensitiveDict: + # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation + # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise + # but here we are making straight requests, so we need todo convert this against apprise's logic + params = CaseInsensitiveDict( + { + unquote_plus(k): unquote_plus(v) + for k, v in parsed_url["qsd"].items() + if k.strip("+-") not in parsed_url["qsd+"] + } + ) + + return params @notify(on="get") @notify(on="gets") @@ -43,42 +73,20 @@ def apprise_custom_api_call_wrapper( **kwargs, ) -> bool: url: str = meta.get("url") - schema: str = meta.get("schema").lower().strip() + schema: str = meta.get("schema") + method: str = re.sub(r"s$", "", schema).upper() # Convert /foobar?+some-header=hello to proper header dictionary - parsed_url: dict[str, str | dict | None] = apprise_parse_url(url) - - headers = CaseInsensitiveDict( - {unquote_plus(k): unquote_plus(v) for k, v in parsed_url["qsd+"].items()} - ) - - # https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation - # In Apprise, it relies on prefixing each request arg with "-", because it uses say &method=update as a flag for apprise - # but here we are making straight requests, so we need todo convert this against apprise's logic - params = CaseInsensitiveDict( - { - unquote_plus(k): unquote_plus(v) - for k, v in parsed_url["qsd"].items() - if k.strip("+-") not in parsed_url["qsd+"] - } - ) + parsed_url: dict[str, str | dict | None] | None = apprise_parse_url(url) + if parsed_url is None: + return False auth = _get_auth(parsed_url=parsed_url) - - # If Content-Type is not specified, guess if it's a JSON body - if headers.get("Content-Type") is None: - try: - json.loads(body) - headers['Content-Type'] = 'application/json; charset=utf-8' - except ValueError: - pass + headers = _get_headers(parsed_url=parsed_url, body=body) + params = _get_params(parsed_url=parsed_url) url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url")) - status_str = '' - has_error = False - method: str = re.sub(r"s$", "", schema).upper() - try: r = requests.request( method=method, From 512cf84ab85e844a6fa3ae173c83849c53c5d932 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 18:38:20 +0100 Subject: [PATCH 13/20] chg(apprise): following Apprise documentation, custom notification handlers must not raise exception but instead return a boolean based on the outcome of the sending process --- changedetectionio/apprise_plugin/__init__.py | 40 +++++++++----------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index c43b0f7aabe..620b2b11cd3 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -12,26 +12,26 @@ def _get_auth(parsed_url: dict) -> str | tuple[str, str]: user: str | None = parsed_url.get("user") password: str | None = parsed_url.get("password") - + if user is not None and password is not None: return (unquote_plus(user), unquote_plus(password)) - + if user is not None: return unquote_plus(user) - + return "" + def _get_headers(parsed_url: dict, body: str) -> CaseInsensitiveDict: headers = CaseInsensitiveDict( - {unquote_plus(k).capitalize(): unquote_plus(v) - for k, v in parsed_url["qsd+"].items()} + {unquote_plus(k).capitalize(): unquote_plus(v) for k, v in parsed_url["qsd+"].items()} ) # If Content-Type is not specified, guess if the body is a valid JSON if headers.get("Content-Type") is None: try: json.loads(body) - headers['Content-Type'] = 'application/json; charset=utf-8' + headers["Content-Type"] = "application/json; charset=utf-8" except Exception: pass @@ -52,6 +52,7 @@ def _get_params(parsed_url: dict) -> CaseInsensitiveDict: return params + @notify(on="get") @notify(on="gets") @notify(on="post") @@ -88,29 +89,24 @@ def apprise_custom_api_call_wrapper( url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url")) try: - r = requests.request( + response = requests.request( method=method, url=url, auth=auth, - data=body.encode("utf-8") if type(body) is str else body, headers=headers, params=params, + data=body.encode("utf-8") if isinstance(body, str) else body, ) - if not (200 <= r.status_code < 300): - status_str = f"Error sending '{method.upper()}' request to {url} - Status: {r.status_code}: '{r.reason}'" - logger.error(status_str) - has_error = True - else: - logger.info(f"Sent '{method.upper()}' request to {url}") - has_error = False + response.raise_for_status() - except requests.RequestException as e: - status_str = f"Error sending '{method.upper()}' request to {url} - {str(e)}" - logger.error(status_str) - has_error = True + logger.info(f"Successfully sent custom notification to {url}") + return True - if has_error: - raise TypeError(status_str) + except requests.RequestException as e: + logger.error(f"Remote host error while sending custom notification to {url}: {e}") + return False - return True + except Exception as e: + logger.error(f"Unexpected error occurred while sending custom notification to {url}: {e}") + return False From fc9d297056d4059fcaf07d98cadb25ff66d88db4 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 18:43:15 +0100 Subject: [PATCH 14/20] chg(apprise): wrapped up all supported method in a set + custom decorator --- changedetectionio/apprise_plugin/__init__.py | 25 ++++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index 620b2b11cd3..c1a9d27804d 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -8,6 +8,16 @@ from loguru import logger from requests.structures import CaseInsensitiveDict +SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"} + + +def notify_supported_methods(func): + for method in SUPPORTED_HTTP_METHODS: + func = notify(on=method)(func) + # Add support for https, for each supported http method + func = notify(on=f"{method}s")(func) + return func + def _get_auth(parsed_url: dict) -> str | tuple[str, str]: user: str | None = parsed_url.get("user") @@ -24,7 +34,7 @@ def _get_auth(parsed_url: dict) -> str | tuple[str, str]: def _get_headers(parsed_url: dict, body: str) -> CaseInsensitiveDict: headers = CaseInsensitiveDict( - {unquote_plus(k).capitalize(): unquote_plus(v) for k, v in parsed_url["qsd+"].items()} + {unquote_plus(k).title(): unquote_plus(v) for k, v in parsed_url["qsd+"].items()} ) # If Content-Type is not specified, guess if the body is a valid JSON @@ -53,18 +63,7 @@ def _get_params(parsed_url: dict) -> CaseInsensitiveDict: return params -@notify(on="get") -@notify(on="gets") -@notify(on="post") -@notify(on="posts") -@notify(on="put") -@notify(on="puts") -@notify(on="delete") -@notify(on="deletes") -@notify(on="patch") -@notify(on="patchs") -@notify(on="head") -@notify(on="heads") +@notify_supported_methods def apprise_custom_api_call_wrapper( body: str, title: str, From fd1b0ce9463351a124429eca227b6bbcf26eba15 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 18:46:11 +0100 Subject: [PATCH 15/20] chg(notifications): ensure that Apprise custom API are registered when invoking `process_notification` --- changedetectionio/blueprint/ui/notification.py | 4 +--- changedetectionio/notification.py | 6 ++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/changedetectionio/blueprint/ui/notification.py b/changedetectionio/blueprint/ui/notification.py index 4334d6d8fd7..07ae7e80d8c 100644 --- a/changedetectionio/blueprint/ui/notification.py +++ b/changedetectionio/blueprint/ui/notification.py @@ -4,6 +4,7 @@ from changedetectionio.store import ChangeDetectionStore from changedetectionio.auth_decorator import login_optionally_required +from changedetectionio.notification import process_notification def construct_blueprint(datastore: ChangeDetectionStore): notification_blueprint = Blueprint('ui_notification', __name__, template_folder="../ui/templates") @@ -20,8 +21,6 @@ def ajax_callback_send_notification_test(watch_uuid=None): from changedetectionio.apprise_asset import apprise_asset apobj = apprise.Apprise(asset=apprise_asset) - # so that the custom endpoints are registered - from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper is_global_settings_form = request.args.get('mode', '') == 'global-settings' is_group_settings_form = request.args.get('mode', '') == 'group-settings' @@ -90,7 +89,6 @@ def ajax_callback_send_notification_test(watch_uuid=None): n_object['as_async'] = False n_object.update(watch.extra_notification_token_values()) - from changedetectionio.notification import process_notification sent_obj = process_notification(n_object, datastore) except Exception as e: diff --git a/changedetectionio/notification.py b/changedetectionio/notification.py index 899e1e136e5..0268e1758fa 100644 --- a/changedetectionio/notification.py +++ b/changedetectionio/notification.py @@ -5,6 +5,8 @@ from loguru import logger from changedetectionio.apprise_asset import APPRISE_AVATAR_URL +from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper # noqa: F401 +from .safe_jinja import render as jinja_render valid_tokens = { 'base_url': '', @@ -40,10 +42,6 @@ def process_notification(n_object, datastore): - # so that the custom endpoints are registered - from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper - - from .safe_jinja import render as jinja_render now = time.time() if n_object.get('notification_timestamp'): logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s") From 351f47d88f9b2d91a4f12b350c7cc0749a1049d6 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 19:15:54 +0100 Subject: [PATCH 16/20] fix(apprise): better handlind of qsd+/- while processing params --- changedetectionio/apprise_plugin/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/__init__.py index c1a9d27804d..2a666551dcf 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/__init__.py @@ -56,7 +56,8 @@ def _get_params(parsed_url: dict) -> CaseInsensitiveDict: { unquote_plus(k): unquote_plus(v) for k, v in parsed_url["qsd"].items() - if k.strip("+-") not in parsed_url["qsd+"] + if k.strip("-") not in parsed_url["qsd-"] + and k.strip("+") not in parsed_url["qsd+"] } ) From a4f55937de70aa38d969cc2aae4355b4dec7a418 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 19:17:03 +0100 Subject: [PATCH 17/20] add(tests): new tests suite for apprise custom API --- .../tests/apprise/test_apprise_asset.py | 3 +- .../apprise/test_apprise_custom_api_call.py | 221 ++++++++++++++++++ 2 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 changedetectionio/tests/apprise/test_apprise_custom_api_call.py diff --git a/changedetectionio/tests/apprise/test_apprise_asset.py b/changedetectionio/tests/apprise/test_apprise_asset.py index 68f726fc592..6e86868ddcb 100644 --- a/changedetectionio/tests/apprise/test_apprise_asset.py +++ b/changedetectionio/tests/apprise/test_apprise_asset.py @@ -1,12 +1,11 @@ import pytest - from apprise import AppriseAsset from changedetectionio.apprise_asset import ( APPRISE_APP_DESC, APPRISE_APP_ID, - APPRISE_AVATAR_URL, APPRISE_APP_URL, + APPRISE_AVATAR_URL, ) diff --git a/changedetectionio/tests/apprise/test_apprise_custom_api_call.py b/changedetectionio/tests/apprise/test_apprise_custom_api_call.py new file mode 100644 index 00000000000..dc88165757a --- /dev/null +++ b/changedetectionio/tests/apprise/test_apprise_custom_api_call.py @@ -0,0 +1,221 @@ +import json +from unittest.mock import patch + +import pytest +import requests +from apprise.utils.parse import parse_url as apprise_parse_url + +from changedetectionio.apprise_plugin import ( + _get_auth, + _get_headers, + _get_params, + apprise_custom_api_call_wrapper, +) + + +@pytest.mark.parametrize( + "url,expected_auth", + [ + ("get://user:pass@localhost:9999", ("user", "pass")), + ("get://user@localhost:9999", "user"), + ("get://localhost:9999", ""), + ("get://user%20name:pass%20word@localhost:9999", ("user name", "pass word")), + ], +) +def test_get_auth(url, expected_auth): + """Test authentication extraction with various URL formats.""" + parsed_url = apprise_parse_url(url) + assert _get_auth(parsed_url) == expected_auth + + +@pytest.mark.parametrize( + "url,body,expected_content_type", + [ + ( + "get://localhost:9999?+content-type=application/xml", + "test", + "application/xml", + ), + ("get://localhost:9999", '{"key": "value"}', "application/json; charset=utf-8"), + ("get://localhost:9999", "plain text", None), + ("get://localhost:9999?+content-type=text/plain", "test", "text/plain"), + ], +) +def test_get_headers(url, body, expected_content_type): + """Test header extraction and content type detection.""" + parsed_url = apprise_parse_url(url) + headers = _get_headers(parsed_url, body) + + if expected_content_type: + assert headers.get("Content-Type") == expected_content_type + + +@pytest.mark.parametrize( + "url,expected_params", + [ + ("get://localhost:9999?param1=value1", {"param1": "value1"}), + ("get://localhost:9999?param1=value1&-param2=ignored", {"param1": "value1"}), + ("get://localhost:9999?param1=value1&+header=test", {"param1": "value1"}), + ( + "get://localhost:9999?encoded%20param=encoded%20value", + {"encoded param": "encoded value"}, + ), + ], +) +def test_get_params(url, expected_params): + """Test parameter extraction with URL encoding and exclusion logic.""" + parsed_url = apprise_parse_url(url) + params = _get_params(parsed_url) + assert dict(params) == expected_params + + +@pytest.mark.parametrize( + "url,schema,method", + [ + ("get://localhost:9999", "get", "GET"), + ("post://localhost:9999", "post", "POST"), + ("delete://localhost:9999", "delete", "DELETE"), + ], +) +@patch("requests.request") +def test_apprise_custom_api_call_success(mock_request, url, schema, method): + """Test successful API calls with different HTTP methods and schemas.""" + mock_request.return_value.raise_for_status.return_value = None + + meta = {"url": url, "schema": schema} + result = apprise_custom_api_call_wrapper( + body="test body", title="Test Title", notify_type="info", meta=meta + ) + + assert result is True + mock_request.assert_called_once() + + call_args = mock_request.call_args + assert call_args[1]["method"] == method.upper() + assert call_args[1]["url"].startswith("http") + + +@patch("requests.request") +def test_apprise_custom_api_call_with_auth(mock_request): + """Test API call with authentication.""" + mock_request.return_value.raise_for_status.return_value = None + + url = "get://user:pass@localhost:9999/secure" + meta = {"url": url, "schema": "get"} + + result = apprise_custom_api_call_wrapper( + body=json.dumps({"key": "value"}), + title="Secure Test", + notify_type="info", + meta=meta, + ) + + assert result is True + mock_request.assert_called_once() + call_args = mock_request.call_args + assert call_args[1]["auth"] == ("user", "pass") + + +@pytest.mark.parametrize( + "exception_type,expected_result", + [ + (requests.RequestException, False), + (requests.HTTPError, False), + (Exception, False), + ], +) +@patch("requests.request") +def test_apprise_custom_api_call_failure(mock_request, exception_type, expected_result): + """Test various failure scenarios.""" + url = "get://localhost:9999/error" + meta = {"url": url, "schema": "get"} + + # Simulate different types of exceptions + mock_request.side_effect = exception_type("Error occurred") + + result = apprise_custom_api_call_wrapper( + body="error body", title="Error Test", notify_type="error", meta=meta + ) + + assert result == expected_result + + +def test_invalid_url_parsing(): + """Test handling of invalid URL parsing.""" + meta = {"url": "invalid://url", "schema": "invalid"} + result = apprise_custom_api_call_wrapper( + body="test", title="Invalid URL", notify_type="info", meta=meta + ) + + assert result is False + + +@pytest.mark.parametrize( + "schema,expected_method", + [ + ("get", "GET"), + ("post", "POST"), + ("put", "PUT"), + ("delete", "DELETE"), + ("patch", "PATCH"), + ("head", "HEAD"), + ], +) +@patch("requests.request") +def test_http_methods(mock_request, schema, expected_method): + """Test all supported HTTP methods.""" + mock_request.return_value.raise_for_status.return_value = None + + url = f"{schema}://localhost:9999" + + result = apprise_custom_api_call_wrapper( + body="test body", + title="Test Title", + notify_type="info", + meta={"url": url, "schema": schema}, + ) + + assert result is True + mock_request.assert_called_once() + + call_args = mock_request.call_args + assert call_args[1]["method"] == expected_method + + +@pytest.mark.parametrize( + "input_schema,expected_method,expected_protocol", + [ + ("gets", "GET", "https"), + ("posts", "POST", "https"), + ("puts", "PUT", "https"), + ("deletes", "DELETE", "https"), + ("patchs", "PATCH", "https"), + ("heads", "HEAD", "https"), + ], +) +@patch("requests.request") +def test_https_method_conversion( + mock_request, input_schema, expected_method, expected_protocol +): + """Validate that methods ending with 's' use HTTPS and correct HTTP method.""" + mock_request.return_value.raise_for_status.return_value = None + + url = f"{input_schema}://localhost:9999" + + result = apprise_custom_api_call_wrapper( + body="test body", + title="Test Title", + notify_type="info", + meta={"url": url, "schema": input_schema}, + ) + + assert result is True + mock_request.assert_called_once() + + call_args = mock_request.call_args + + # Verify correct HTTP method + assert call_args[1]["method"] == expected_method + + # Verify URL starts with HTTPS + assert call_args[1]["url"].startswith(expected_protocol) \ No newline at end of file From f9f8d8fbedda39b67b80ad60182da52141826e34 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 19:23:10 +0100 Subject: [PATCH 18/20] chg(tests): dynamically load supported http methods --- .../apprise/test_apprise_custom_api_call.py | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/changedetectionio/tests/apprise/test_apprise_custom_api_call.py b/changedetectionio/tests/apprise/test_apprise_custom_api_call.py index dc88165757a..541f60f8297 100644 --- a/changedetectionio/tests/apprise/test_apprise_custom_api_call.py +++ b/changedetectionio/tests/apprise/test_apprise_custom_api_call.py @@ -10,6 +10,7 @@ _get_headers, _get_params, apprise_custom_api_call_wrapper, + SUPPORTED_HTTP_METHODS, ) @@ -153,12 +154,8 @@ def test_invalid_url_parsing(): @pytest.mark.parametrize( "schema,expected_method", [ - ("get", "GET"), - ("post", "POST"), - ("put", "PUT"), - ("delete", "DELETE"), - ("patch", "PATCH"), - ("head", "HEAD"), + (http_method, http_method.upper()) + for http_method in SUPPORTED_HTTP_METHODS ], ) @patch("requests.request") @@ -183,19 +180,15 @@ def test_http_methods(mock_request, schema, expected_method): @pytest.mark.parametrize( - "input_schema,expected_method,expected_protocol", + "input_schema,expected_method", [ - ("gets", "GET", "https"), - ("posts", "POST", "https"), - ("puts", "PUT", "https"), - ("deletes", "DELETE", "https"), - ("patchs", "PATCH", "https"), - ("heads", "HEAD", "https"), + (f"{http_method}s", http_method.upper()) + for http_method in SUPPORTED_HTTP_METHODS ], ) @patch("requests.request") def test_https_method_conversion( - mock_request, input_schema, expected_method, expected_protocol + mock_request, input_schema, expected_method ): """Validate that methods ending with 's' use HTTPS and correct HTTP method.""" mock_request.return_value.raise_for_status.return_value = None @@ -214,8 +207,5 @@ def test_https_method_conversion( call_args = mock_request.call_args - # Verify correct HTTP method assert call_args[1]["method"] == expected_method - - # Verify URL starts with HTTPS - assert call_args[1]["url"].startswith(expected_protocol) \ No newline at end of file + assert call_args[1]["url"].startswith("https") From a60a6c87542a1131dc17ef79680dea73de247063 Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 19:46:01 +0100 Subject: [PATCH 19/20] fix(forms): effectively load custom handlers while validating apprise urls --- changedetectionio/forms.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 96303047e99..5db3d9efb63 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -305,10 +305,10 @@ def __init__(self, message=None): def __call__(self, form, field): import apprise - apobj = apprise.Apprise() - - # so that the custom endpoints are registered from .apprise_asset import apprise_asset + from .apprise_plugin import apprise_custom_api_call_wrapper # noqa: F401 + + apobj = apprise.Apprise(asset=apprise_asset) for server_url in field.data: url = server_url.strip() From f0fde9b78a40f3fba1d8f720eb08e698415c922f Mon Sep 17 00:00:00 2001 From: Luca Cirillo Date: Tue, 25 Mar 2025 19:49:49 +0100 Subject: [PATCH 20/20] chore(apprise): moved all Apprise stuffs into apprise_plugin --- .../assets.py} | 0 .../{__init__.py => custom_handlers.py} | 2 +- changedetectionio/blueprint/ui/notification.py | 3 ++- changedetectionio/forms.py | 4 ++-- changedetectionio/notification.py | 6 +++--- .../apprise/test_apprise_custom_api_call.py | 16 ++++++++-------- 6 files changed, 16 insertions(+), 15 deletions(-) rename changedetectionio/{apprise_asset.py => apprise_plugin/assets.py} (100%) rename changedetectionio/apprise_plugin/{__init__.py => custom_handlers.py} (98%) diff --git a/changedetectionio/apprise_asset.py b/changedetectionio/apprise_plugin/assets.py similarity index 100% rename from changedetectionio/apprise_asset.py rename to changedetectionio/apprise_plugin/assets.py diff --git a/changedetectionio/apprise_plugin/__init__.py b/changedetectionio/apprise_plugin/custom_handlers.py similarity index 98% rename from changedetectionio/apprise_plugin/__init__.py rename to changedetectionio/apprise_plugin/custom_handlers.py index 2a666551dcf..1fd28b41616 100644 --- a/changedetectionio/apprise_plugin/__init__.py +++ b/changedetectionio/apprise_plugin/custom_handlers.py @@ -65,7 +65,7 @@ def _get_params(parsed_url: dict) -> CaseInsensitiveDict: @notify_supported_methods -def apprise_custom_api_call_wrapper( +def apprise_http_custom_handler( body: str, title: str, notify_type: str, diff --git a/changedetectionio/blueprint/ui/notification.py b/changedetectionio/blueprint/ui/notification.py index 07ae7e80d8c..ac23ac3c145 100644 --- a/changedetectionio/blueprint/ui/notification.py +++ b/changedetectionio/blueprint/ui/notification.py @@ -18,7 +18,8 @@ def ajax_callback_send_notification_test(watch_uuid=None): # Watch_uuid could be unset in the case it`s used in tag editor, global settings import apprise - from changedetectionio.apprise_asset import apprise_asset + from ...apprise_plugin.assets import apprise_asset + from ...apprise_plugin.custom_handlers import apprise_http_custom_handler # noqa: F401 apobj = apprise.Apprise(asset=apprise_asset) is_global_settings_form = request.args.get('mode', '') == 'global-settings' diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 5db3d9efb63..eae187f47e2 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -305,8 +305,8 @@ def __init__(self, message=None): def __call__(self, form, field): import apprise - from .apprise_asset import apprise_asset - from .apprise_plugin import apprise_custom_api_call_wrapper # noqa: F401 + from .apprise_plugin.assets import apprise_asset + from .apprise_plugin.custom_handlers import apprise_http_custom_handler # noqa: F401 apobj = apprise.Apprise(asset=apprise_asset) diff --git a/changedetectionio/notification.py b/changedetectionio/notification.py index 0268e1758fa..857a137d53c 100644 --- a/changedetectionio/notification.py +++ b/changedetectionio/notification.py @@ -4,8 +4,8 @@ import apprise from loguru import logger -from changedetectionio.apprise_asset import APPRISE_AVATAR_URL -from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper # noqa: F401 +from .apprise_plugin.assets import APPRISE_AVATAR_URL +from .apprise_plugin.custom_handlers import apprise_http_custom_handler # noqa: F401 from .safe_jinja import render as jinja_render valid_tokens = { @@ -65,7 +65,7 @@ def process_notification(n_object, datastore): # raise it as an exception sent_objs = [] - from .apprise_asset import apprise_asset + from .apprise_plugin.assets import apprise_asset if 'as_async' in n_object: apprise_asset.async_mode = n_object.get('as_async') diff --git a/changedetectionio/tests/apprise/test_apprise_custom_api_call.py b/changedetectionio/tests/apprise/test_apprise_custom_api_call.py index 541f60f8297..45271051d03 100644 --- a/changedetectionio/tests/apprise/test_apprise_custom_api_call.py +++ b/changedetectionio/tests/apprise/test_apprise_custom_api_call.py @@ -5,11 +5,11 @@ import requests from apprise.utils.parse import parse_url as apprise_parse_url -from changedetectionio.apprise_plugin import ( +from ...apprise_plugin.custom_handlers import ( _get_auth, _get_headers, _get_params, - apprise_custom_api_call_wrapper, + apprise_http_custom_handler, SUPPORTED_HTTP_METHODS, ) @@ -84,7 +84,7 @@ def test_apprise_custom_api_call_success(mock_request, url, schema, method): mock_request.return_value.raise_for_status.return_value = None meta = {"url": url, "schema": schema} - result = apprise_custom_api_call_wrapper( + result = apprise_http_custom_handler( body="test body", title="Test Title", notify_type="info", meta=meta ) @@ -104,7 +104,7 @@ def test_apprise_custom_api_call_with_auth(mock_request): url = "get://user:pass@localhost:9999/secure" meta = {"url": url, "schema": "get"} - result = apprise_custom_api_call_wrapper( + result = apprise_http_custom_handler( body=json.dumps({"key": "value"}), title="Secure Test", notify_type="info", @@ -134,7 +134,7 @@ def test_apprise_custom_api_call_failure(mock_request, exception_type, expected_ # Simulate different types of exceptions mock_request.side_effect = exception_type("Error occurred") - result = apprise_custom_api_call_wrapper( + result = apprise_http_custom_handler( body="error body", title="Error Test", notify_type="error", meta=meta ) @@ -144,7 +144,7 @@ def test_apprise_custom_api_call_failure(mock_request, exception_type, expected_ def test_invalid_url_parsing(): """Test handling of invalid URL parsing.""" meta = {"url": "invalid://url", "schema": "invalid"} - result = apprise_custom_api_call_wrapper( + result = apprise_http_custom_handler( body="test", title="Invalid URL", notify_type="info", meta=meta ) @@ -165,7 +165,7 @@ def test_http_methods(mock_request, schema, expected_method): url = f"{schema}://localhost:9999" - result = apprise_custom_api_call_wrapper( + result = apprise_http_custom_handler( body="test body", title="Test Title", notify_type="info", @@ -195,7 +195,7 @@ def test_https_method_conversion( url = f"{input_schema}://localhost:9999" - result = apprise_custom_api_call_wrapper( + result = apprise_http_custom_handler( body="test body", title="Test Title", notify_type="info",