Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
5108194
Re #3159 - better send test handling
dgtlmoon Apr 30, 2025
97e6933
Merge branch 'master' into 3159-test-notification-send
dgtlmoon Aug 21, 2025
017898d
Update notification method with new queue system
dgtlmoon Aug 21, 2025
5dd00c1
UI - Fixing tabs handling
dgtlmoon Aug 22, 2025
bfd5432
WIP
dgtlmoon Aug 22, 2025
9f0bc06
Use iframe for preview
dgtlmoon Aug 22, 2025
e7d82bb
WIP
dgtlmoon Aug 28, 2025
a9a0ae0
WIP
dgtlmoon Aug 28, 2025
a8e4027
little cleanup for tests
dgtlmoon Aug 28, 2025
abd24c2
Fix up selection of correct group uuid
dgtlmoon Aug 28, 2025
4ea9013
Adding ability to use a wrapping template "notification.html"
dgtlmoon Aug 28, 2025
0820dc1
Merge branch 'master' into 3159-test-notification-send
dgtlmoon Aug 28, 2025
dfd7e71
Merge branch 'master' into 3159-test-notification-send
dgtlmoon Aug 29, 2025
0bfa9fe
Fix links from being mashed
dgtlmoon Aug 29, 2025
c2eb736
WIP
dgtlmoon Sep 10, 2025
c77a970
Merge branch 'master' into 3159-test-notification-send
dgtlmoon Sep 10, 2025
5298748
New default notification
dgtlmoon Sep 10, 2025
c1a92de
little styling fixup
dgtlmoon Sep 10, 2025
6e1c53b
fix error handler
dgtlmoon Sep 15, 2025
623f056
Fixing markup safety
dgtlmoon Sep 15, 2025
4ab222e
Fixing error handlers
dgtlmoon Sep 15, 2025
8e68043
oops
dgtlmoon Sep 15, 2025
d90ad2d
oops
dgtlmoon Sep 15, 2025
74c275d
WIP
dgtlmoon Sep 16, 2025
660bf3e
HTML improvements
dgtlmoon Sep 16, 2025
7ba14b6
Re #3426
dgtlmoon Sep 16, 2025
f730db8
fix defaults
dgtlmoon Sep 16, 2025
1916299
improved error handling
dgtlmoon Sep 16, 2025
4608989
Maybe this fixes it
dgtlmoon Sep 16, 2025
0058103
Add missing extension
dgtlmoon Sep 16, 2025
ec43d1a
Merge branch 'master' into 3159-test-notification-send
dgtlmoon Sep 16, 2025
0781de9
Merge branch 'master' into 3159-test-notification-send
dgtlmoon Sep 17, 2025
fe800fd
fix colour on diff_added/diff_removed
dgtlmoon Sep 18, 2025
4216ffe
some WIP
dgtlmoon Sep 18, 2025
d318bb7
Merge branch 'master' into 3159-test-notification-send
dgtlmoon Oct 3, 2025
5484b23
Merge branch 'master' into 3159-test-notification-send
dgtlmoon Oct 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changedetectionio/async_update_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore):
try:
processor_module = importlib.import_module(processor_module_name)
except ModuleNotFoundError as e:
print(f"Processor module '{processor}' not found.")
logger.error(f"Processor module '{processor}' not found.")
raise e

update_handler = processor_module.perform_site_check(datastore=datastore,
Expand Down
42 changes: 24 additions & 18 deletions changedetectionio/blueprint/settings/templates/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
{% from '_common_fields.html' import render_common_settings_form %}
<script>
const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}";
const notification_test_render_preview_url="{{url_for('ui.ui_notification.ajax_callback_test_render_preview', mode="global-settings")}}";
{% if emailprefix %}
const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}');
{% endif %}
Expand Down Expand Up @@ -43,10 +44,6 @@
</div>
</div>
</div>
<div class="pure-control-group">
{{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }}
<span class="pure-form-message-inline">Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later</span>
</div>
<div class="pure-control-group">
{{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }}
<span class="pure-form-message-inline">After this many consecutive times that the CSS/xPath filter is missing, send a notification
Expand Down Expand Up @@ -133,6 +130,10 @@
<span class="pure-form-message-inline">Number of concurrent workers to process watches. More workers = faster processing but higher memory usage.<br>
Currently running: <strong>{{ worker_info.count }}</strong> operational {{ worker_info.type }} workers{% if worker_info.active_workers > 0 %} ({{ worker_info.active_workers }} actively processing){% endif %}.</span>
</div>
<div class="pure-control-group">
{{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }}
<span class="pure-form-message-inline">Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later</span>
</div>
<div class="pure-control-group inline-radio">
{{ render_field(form.requests.form.default_ua) }}
<span class="pure-form-message-inline">
Expand Down Expand Up @@ -200,21 +201,12 @@
</div>

<div class="tab-pane-inner" id="api">
<h4>API Access</h4>
<p>Drive your changedetection.io via API, More about <a href="https://changedetection.io/docs/api_v1/index.html">API access and examples here</a>.</p>
<p>
<strong>Chrome extension and API Access</strong><br>
</p>

<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.api_access_token_enabled) }}
<div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header - required for the Chrome Extension to work</div><br>
<div class="pure-form-message-inline"><br>API Key <span id="api-key">{{api_key}}</span>
<span style="display:none;" id="api-key-copy" >copy</span>
</div>
</div>
<div class="pure-control-group">
<a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">Regenerate API key</a>
</div>
<div class="pure-control-group">
<h4>Chrome Extension</h4>
<div class="pure-control-group border-fieldset">
<strong>Chrome Extension</strong><br>
<p>Easily add any web-page to your changedetection.io installation from within Chrome.</p>
<strong>Step 1</strong> Install the extension, <strong>Step 2</strong> Navigate to this page,
<strong>Step 3</strong> Open the extension from the toolbar and click "<i>Sync API Access</i>"
Expand All @@ -227,6 +219,20 @@ <h4>Chrome Extension</h4>
</a>
</p>
</div>
<div class="pure-control-group border-fieldset">
Drive your changedetection.io via API, More about <a href="https://changedetection.io/docs/api_v1/index.html">API access and examples here</a>.<br>
<p>
{{ render_checkbox_field(form.application.form.api_access_token_enabled) }}
</p>
<div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header - required for the Chrome Extension to work</div><br>
<div class="pure-form-message-inline"><br>API Key <span id="api-key">{{api_key}}</span>
<span style="display:none;" id="api-key-copy" >copy</span>
</div>
<p>
<a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">Regenerate API key</a>
</p>
</div>

</div>
<div class="tab-pane-inner" id="timedate">
<div class="pure-control-group">
Expand Down
4 changes: 4 additions & 0 deletions changedetectionio/blueprint/tags/templates/edit-tag.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
{% from '_common_fields.html' import render_common_settings_form %}
<script>
const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="group-settings")}}";
const notification_test_render_preview_url="{{url_for('ui.ui_notification.ajax_callback_test_render_preview', mode="group-settings", watch_uuid=data.uuid)}}";
//alert(notification_test_render_preview_url)
</script>

<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
Expand All @@ -19,6 +21,8 @@

<script src="{{url_for('static_content', group='js', filename='watch-settings.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script>


<div class="edit-form monospaced-textarea">

Expand Down
4 changes: 2 additions & 2 deletions changedetectionio/blueprint/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,14 @@ def _handle_operations(op, uuids, datastore, worker_handler, update_q, queuedWat
for uuid in uuids:
watch_check_update.send(watch_uuid=uuid)

def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handler, queuedWatchMetaData, watch_check_update):
def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handler, queuedWatchMetaData, watch_check_update, notification_q):
ui_blueprint = Blueprint('ui', __name__, template_folder="templates")

# Register the edit blueprint
edit_blueprint = construct_edit_blueprint(datastore, update_q, queuedWatchMetaData)
ui_blueprint.register_blueprint(edit_blueprint)

# Register the notification blueprint
# Register the notification blueprint - mostly used for sending test
notification_blueprint = construct_notification_blueprint(datastore)
ui_blueprint.register_blueprint(notification_blueprint)

Expand Down
84 changes: 65 additions & 19 deletions changedetectionio/blueprint/ui/notification.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,85 @@
from flask import Blueprint, request, make_response
from flask import Blueprint, request, make_response, jsonify
import random
from loguru import logger

from changedetectionio.notification.handler import process_notification
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required

def construct_blueprint(datastore: ChangeDetectionStore):
notification_blueprint = Blueprint('ui_notification', __name__, template_folder="../ui/templates")



@notification_blueprint.route("/notification/render-preview/<string:watch_uuid>", methods=['POST'])
@notification_blueprint.route("/notification/render-preview", methods=['POST'])
@notification_blueprint.route("/notification/render-preview/", methods=['POST'])
@login_optionally_required
def ajax_callback_test_render_preview(watch_uuid=None):
return ajax_callback_send_notification_test(watch_uuid=watch_uuid, send_as_null_test=True)

# AJAX endpoint for sending a test
@notification_blueprint.route("/notification/send-test/<string:watch_uuid>", methods=['POST'])
@notification_blueprint.route("/notification/send-test", methods=['POST'])
@notification_blueprint.route("/notification/send-test/", methods=['POST'])
@login_optionally_required
def ajax_callback_send_notification_test(watch_uuid=None):
def ajax_callback_send_notification_test(watch_uuid=None, send_as_null_test=False):

# Watch_uuid could be unset in the case it`s used in tag editor, global settings
import apprise
from changedetectionio.notification.handler import process_notification
from urllib.parse import urlparse
from changedetectionio.notification.apprise_plugin.assets import apprise_asset

from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
# Necessary so that we import our custom handlers
from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler, apprise_null_custom_handler

apobj = apprise.Apprise(asset=apprise_asset)
sent_obj = {}

is_global_settings_form = request.args.get('mode', '') == 'global-settings'
is_group_settings_form = request.args.get('mode', '') == 'group-settings'

# Use an existing random one on the global/main settings form
if not watch_uuid and (is_global_settings_form or is_group_settings_form) \
and datastore.data.get('watching'):
logger.debug(f"Send test notification - Choosing random Watch {watch_uuid}")
if not watch_uuid and is_global_settings_form and datastore.data.get('watching'):
watch_uuid = random.choice(list(datastore.data['watching'].keys()))
logger.debug(f"Send test notification - Chose random watch UUID: {watch_uuid}")

if is_group_settings_form and datastore.data.get('watching'):
logger.debug(f"Send test notification - Choosing random Watch from group {watch_uuid}")
matching_watches = [uuid for uuid, watch in datastore.data['watching'].items() if watch.get('tags') and watch_uuid in watch['tags']]
if matching_watches:
watch_uuid = random.choice(matching_watches)
else:
# Just fallback to any
watch_uuid = random.choice(list(datastore.data['watching'].keys()))

if not watch_uuid:
return make_response("Error: You must have atleast one watch configured for 'test notification' to work", 400)

watch = datastore.data['watching'].get(watch_uuid)

notification_urls = None
notification_urls = []

if request.form.get('notification_urls'):
notification_urls = request.form['notification_urls'].strip().splitlines()
if send_as_null_test:
test_schema = ''
try:
if request.form.get('notification_urls') and '://' in request.form.get('notification_urls'):
first_test_notification_url = request.form['notification_urls'].strip().splitlines()[0]
test_schema = urlparse(first_test_notification_url).scheme.lower().strip()
except Exception as e:
logger.error(f"Error trying to get a test schema based on the first notification_url {str(e)}")

notification_urls = [
# Null lets us do the whole chain of the same code without any extra repeated code
f'null://null-test-just-to-render-everything-on-the-same-codepath-and-get-preview?test_schema={test_schema}'
]

else:
if request.form.get('notification_urls'):
notification_urls += request.form['notification_urls'].strip().splitlines()

if not notification_urls:
logger.debug("Test notification - Trying by group/tag in the edit form if available")
# @todo this logic is not clear, omegaconf?
# On an edit page, we should also fire off to the tags if they have notifications
if request.form.get('tags') and request.form['tags'].strip():
for k in request.form['tags'].split(','):
Expand All @@ -58,23 +93,28 @@ def ajax_callback_send_notification_test(watch_uuid=None):
notification_urls = datastore.data['settings']['application']['notification_urls']

if not notification_urls:
return 'Error: No Notification URLs set/found'
return make_response("Error: No Notification URLs set/found.", 400)

for n_url in notification_urls:
if len(n_url.strip()):
if not apobj.add(n_url):
return f'Error: {n_url} is not a valid AppRise URL.'
return make_response(f'Error: {n_url} is not a valid AppRise URL.', 400)

try:
# use the same as when it is triggered, but then override it with the form test values
n_object = {
'watch_url': request.form.get('window_url', "https://changedetection.io"),
'notification_urls': notification_urls
'notification_urls': notification_urls,
'uuid': watch_uuid # Ensure uuid is present so diff rendering works
}

# Only use if present, if not set in n_object it should use the default system value
if 'notification_format' in request.form and request.form['notification_format'].strip():
n_object['notification_format'] = request.form.get('notification_format', '').strip()
notif_format = request.form.get('notification_format', '').strip()
# Use it if provided and not "System default", otherwise fall back
if notif_format and notif_format != 'System default':
n_object['notification_format'] = notif_format
else:
n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format')

if 'notification_title' in request.form and request.form['notification_title'].strip():
n_object['notification_title'] = request.form.get('notification_title', '').strip()
Expand All @@ -92,17 +132,23 @@ def ajax_callback_send_notification_test(watch_uuid=None):

n_object['as_async'] = False
n_object.update(watch.extra_notification_token_values())
sent_obj = process_notification(n_object, datastore)

# This uses the same processor that the queue runner uses
# @todo - Split the notification URLs so we know which one worked, maybe highlight them in green in the UI
result = process_notification(n_object, datastore)
if result:
sent_obj['result'] = result[0]
sent_obj['status'] = 'OK - Sent test notifications'

except Exception as e:
e_str = str(e)
# Remove this text which is not important and floods the container
e_str = e_str.replace(
"DEBUG - <class 'apprise.decorators.base.CustomNotifyPlugin.instantiate_plugin.<locals>.CustomNotifyPluginWrapper'>",
'')

return make_response(e_str, 400)

return 'OK - Sent test notifications'
# it will be a list of things reached, for this purpose just the first is good so we can see the body that was sent
return make_response(sent_obj, 200)

return notification_blueprint
7 changes: 4 additions & 3 deletions changedetectionio/blueprint/ui/templates/edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
const email_notification_prefix=JSON.parse('{{ emailprefix|tojson }}');
{% endif %}
const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', watch_uuid=uuid)}}";
const notification_test_render_preview_url="{{url_for('ui.ui_notification.ajax_callback_test_render_preview', watch_uuid=uuid)}}";
const playwright_enabled={% if playwright_enabled %}true{% else %}false{% endif %};
const recheck_proxy_start_url="{{url_for('check_proxies.start_check', uuid=uuid)}}";
const proxy_recheck_status_url="{{url_for('check_proxies.get_recheck_status', uuid=uuid)}}";
Expand Down Expand Up @@ -356,12 +357,12 @@ <h3>Text filtering</h3>
</script>
<br>
{#<div id="text-preview-controls"><span id="text-preview-refresh" class="pure-button button-xsmall">Refresh</span></div>#}
<div class="minitabs-wrapper">
<div class="minitabs-wrapper" id="filter-preview-minitabs">
<div class="minitabs-content">
<div id="text-preview-inner" class="monospace-preview">
<div id="text-preview-inner" class="tab-contents-monospace-preview">
<p>Loading...</p>
</div>
<div id="text-preview-before-inner" style="display: none;" class="monospace-preview">
<div id="text-preview-before-inner" style="display: none;" class="tab-contents-monospace-preview">
<p>Loading...</p>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions changedetectionio/conditions/pluggy_interface.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pluggy
import os
import importlib
import sys
from loguru import logger
from . import default_plugin

# ✅ Ensure that the namespace in HookspecMarker matches PluginManager
Expand Down Expand Up @@ -65,7 +65,7 @@ def load_plugins_from_directory():
# Register the plugin with pluggy
plugin_manager.register(module, module_name)
except (ImportError, AttributeError) as e:
print(f"Error loading plugin {module_name}: {e}")
logger.critical(f"Error loading plugin {module_name}: {e}")

# Load plugins from the plugins directory
load_plugins_from_directory()
Expand Down
2 changes: 1 addition & 1 deletion changedetectionio/flask_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ def static_content(group, filename):

# watchlist UI buttons etc
import changedetectionio.blueprint.ui as ui
app.register_blueprint(ui.construct_blueprint(datastore, update_q, worker_handler, queuedWatchMetaData, watch_check_update))
app.register_blueprint(ui.construct_blueprint(datastore, update_q, worker_handler, queuedWatchMetaData, watch_check_update, notification_q))

import changedetectionio.blueprint.watchlist as watchlist
app.register_blueprint(watchlist.construct_blueprint(datastore=datastore, update_q=update_q, queuedWatchMetaData=queuedWatchMetaData), url_prefix='')
Expand Down
6 changes: 4 additions & 2 deletions changedetectionio/model/Watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,8 +642,10 @@ def extra_notification_token_values(self):

def extra_notification_token_placeholder_info(self):
# Used for providing extra tokens
# return [('widget', "Get widget amounts")]
return []
values = []
values.append(('watch_html_link', "Link to URL as <a href>"))
values.append(('watch_url_raw', "Raw URL/link before any jinja2 macro"))
return values


def extract_regex_from_all_history(self, regex):
Expand Down
Loading
Loading