Compare commits

...

5 Commits

Author SHA1 Message Date
dgtlmoon
9ed50c160d Work on converting to a notification-profile instead of settings per watch/group 2026-03-21 19:44:03 +01:00
dgtlmoon
b98f55030a Restock - Add previous_price to restock values #3987 (#3993)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-20 18:43:36 +01:00
dgtlmoon
6181b09b16 UI - Scan/check all proxies - Regression fix from earlier refactor
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-20 11:40:45 +01:00
dgtlmoon
5f9fa15a6a Realtime - Suppress socket.io errors in logs (#3991)
Some checks failed
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-03-19 18:02:07 +01:00
dgtlmoon
34c2c05bc5 UI - Text tidyup (#3989) 2026-03-19 15:57:05 +01:00
39 changed files with 1818 additions and 588 deletions

View File

@@ -2,27 +2,56 @@ from flask_restful import Resource, abort
from flask import request
from . import auth, validate_openapi_request
_API_PROFILE_NAME = "API Default"
def _get_api_profile(datastore):
"""Return (uuid, profile_dict) for the API-managed system profile, or (None, None)."""
profiles = datastore.data['settings']['application'].get('notification_profile_data', {})
for uid, p in profiles.items():
if p.get('name') == _API_PROFILE_NAME:
return uid, p
return None, None
def _ensure_api_profile(datastore, urls):
"""Create or update the API Default profile and ensure it's linked to system."""
import uuid as uuid_mod
app = datastore.data['settings']['application']
app.setdefault('notification_profile_data', {})
app.setdefault('notification_profiles', [])
uid, profile = _get_api_profile(datastore)
if uid is None:
uid = str(uuid_mod.uuid4())
profile = {'uuid': uid, 'name': _API_PROFILE_NAME, 'type': 'apprise', 'config': {}}
app['notification_profile_data'][uid] = profile
profile['config']['notification_urls'] = urls
if uid not in app['notification_profiles']:
app['notification_profiles'].append(uid)
datastore.needs_write = True
return uid, profile
class Notifications(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getNotifications')
def get(self):
"""Return Notification URL List."""
"""Return Notification URL List (from the API Default profile)."""
_, profile = _get_api_profile(self.datastore)
urls = profile['config'].get('notification_urls', []) if profile else []
return {'notification_urls': urls}, 200
notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])
return {
'notification_urls': notification_urls,
}, 200
@auth.check_token
@validate_openapi_request('addNotifications')
def post(self):
"""Create Notification URLs."""
"""Add Notification URLs to the API Default profile."""
json_data = request.get_json()
notification_urls = json_data.get("notification_urls", [])
@@ -32,23 +61,27 @@ class Notifications(Resource):
except ValidationError as e:
return str(e), 400
added_urls = []
_, profile = _get_api_profile(self.datastore)
existing = list(profile['config'].get('notification_urls', []) if profile else [])
added = []
for url in notification_urls:
clean_url = url.strip()
added_url = self.datastore.add_notification_url(clean_url)
if added_url:
added_urls.append(added_url)
clean = url.strip()
if clean and clean not in existing:
existing.append(clean)
added.append(clean)
if not added_urls:
if not added:
return "No valid notification URLs were added", 400
return {'notification_urls': added_urls}, 201
_ensure_api_profile(self.datastore, existing)
self.datastore.commit()
return {'notification_urls': existing}, 201
@auth.check_token
@validate_openapi_request('replaceNotifications')
def put(self):
"""Replace Notification URLs."""
"""Replace Notification URLs in the API Default profile."""
json_data = request.get_json()
notification_urls = json_data.get("notification_urls", [])
@@ -57,47 +90,61 @@ class Notifications(Resource):
validate_notification_urls(notification_urls)
except ValidationError as e:
return str(e), 400
if not isinstance(notification_urls, list):
return "Invalid input format", 400
clean_urls = [url.strip() for url in notification_urls if isinstance(url, str)]
self.datastore.data['settings']['application']['notification_urls'] = clean_urls
self.datastore.commit()
if clean_urls:
_ensure_api_profile(self.datastore, clean_urls)
else:
# Empty list: remove the profile entirely
uid, _ = _get_api_profile(self.datastore)
if uid:
app = self.datastore.data['settings']['application']
app['notification_profile_data'].pop(uid, None)
if uid in app.get('notification_profiles', []):
app['notification_profiles'].remove(uid)
self.datastore.needs_write = True
self.datastore.commit()
return {'notification_urls': clean_urls}, 200
@auth.check_token
@validate_openapi_request('deleteNotifications')
def delete(self):
"""Delete Notification URLs."""
"""Delete specific Notification URLs from the API Default profile."""
json_data = request.get_json()
urls_to_delete = json_data.get("notification_urls", [])
if not isinstance(urls_to_delete, list):
abort(400, message="Expected a list of notification URLs.")
notification_urls = self.datastore.data['settings']['application'].get('notification_urls', [])
deleted = []
uid, profile = _get_api_profile(self.datastore)
if not profile:
abort(400, message="No matching notification URLs found.")
current = list(profile['config'].get('notification_urls', []))
deleted = []
for url in urls_to_delete:
clean_url = url.strip()
if clean_url in notification_urls:
notification_urls.remove(clean_url)
deleted.append(clean_url)
clean = url.strip()
if clean in current:
current.remove(clean)
deleted.append(clean)
if not deleted:
abort(400, message="No matching notification URLs found.")
self.datastore.data['settings']['application']['notification_urls'] = notification_urls
profile['config']['notification_urls'] = current
self.datastore.needs_write = True
self.datastore.commit()
return 'OK', 204
def validate_notification_urls(notification_urls):
from changedetectionio.forms import ValidateAppRiseServers
validator = ValidateAppRiseServers()
class DummyForm: pass
dummy_form = DummyForm()
field = type("Field", (object,), {"data": notification_urls, "gettext": lambda self, x: x})()
validator(dummy_form, field)
validator(dummy_form, field)

View File

@@ -40,12 +40,13 @@ def construct_blueprint(datastore: ChangeDetectionStore):
contents = ''
now = time.time()
try:
import asyncio
processor_module = importlib.import_module("changedetectionio.processors.text_json_diff.processor")
update_handler = processor_module.perform_site_check(datastore=datastore,
watch_uuid=uuid
)
update_handler.call_browser(preferred_proxy_id=preferred_proxy)
asyncio.run(update_handler.call_browser(preferred_proxy_id=preferred_proxy))
# title, size is len contents not len xfer
except content_fetcher_exceptions.Non200ErrorCodeReceived as e:
if e.status_code == 404:

View File

@@ -0,0 +1,211 @@
import uuid as uuid_mod
from flask import Blueprint, request, render_template, flash, redirect, url_for, make_response
from flask_babel import gettext
from loguru import logger
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
def construct_blueprint(datastore: ChangeDetectionStore):
bp = Blueprint('notification_profiles', __name__, template_folder="templates")
def _profiles():
return datastore.data['settings']['application'].setdefault('notification_profile_data', {})
@bp.route("/", methods=['GET'])
@login_optionally_required
def index():
from changedetectionio.notification_profiles.registry import registry
from changedetectionio.notification_profiles.log import read_profile_log
profiles = _profiles()
# Count how many watches/tags reference each profile
usage = {}
for watch in datastore.data['watching'].values():
for u in watch.get('notification_profiles', []):
usage[u] = usage.get(u, 0) + 1
for tag in datastore.data['settings']['application'].get('tags', {}).values():
for u in tag.get('notification_profiles', []):
usage[u] = usage.get(u, 0) + 1
# Most-recent log entry per profile (for the Last result column)
last_log = {}
for uid in profiles:
entries = read_profile_log(datastore.datastore_path, uid)
if entries:
last_log[uid] = entries[0] # newest first
return render_template(
"notification_profiles/list.html",
profiles=profiles,
registry=registry,
usage=usage,
last_log=last_log,
)
@bp.route("/new", methods=['GET', 'POST'])
@bp.route("/<uuid_str:profile_uuid>", methods=['GET', 'POST'])
@login_optionally_required
def edit(profile_uuid=None):
from changedetectionio.notification_profiles.registry import registry
from .forms import NotificationProfileForm
profiles = _profiles()
existing = profiles.get(profile_uuid, {}) if profile_uuid else {}
form = NotificationProfileForm(
request.form if request.method == 'POST' else None,
data=existing or None,
)
if request.method == 'POST' and form.validate():
profile_type = form.profile_type.data or 'apprise'
type_handler = registry.get(profile_type)
# Build type-specific config from submitted form data
config = _extract_config(request.form, profile_type)
try:
type_handler.validate(config)
except ValueError as e:
flash(str(e), 'error')
return render_template("notification_profiles/edit.html",
form=form, profile_uuid=profile_uuid,
registry=registry, existing=existing)
uid = profile_uuid or str(uuid_mod.uuid4())
profiles[uid] = {
'uuid': uid,
'name': form.name.data.strip(),
'type': profile_type,
'config': config,
}
datastore.commit()
flash(gettext("Notification profile saved."), 'notice')
return redirect(url_for('notification_profiles.index'))
return render_template(
"notification_profiles/edit.html",
form=form,
profile_uuid=profile_uuid,
registry=registry,
existing=existing,
)
@bp.route("/<uuid_str:profile_uuid>/delete", methods=['POST'])
@login_optionally_required
def delete(profile_uuid):
profiles = _profiles()
if profile_uuid not in profiles:
flash(gettext("Profile not found."), 'error')
return redirect(url_for('notification_profiles.index'))
# Warn if in use — but allow deletion
usage_count = sum(
1 for w in datastore.data['watching'].values()
if profile_uuid in w.get('notification_profiles', [])
)
del profiles[profile_uuid]
datastore.commit()
if usage_count:
flash(gettext("Profile deleted (was linked to %(n)d watch(es)).", n=usage_count), 'notice')
else:
flash(gettext("Profile deleted."), 'notice')
return redirect(url_for('notification_profiles.index'))
@bp.route("/<uuid_str:profile_uuid>/test", methods=['POST'])
@login_optionally_required
def test(profile_uuid):
"""Fire a test notification for a saved profile."""
from changedetectionio.notification_service import NotificationContextData, set_basic_notification_vars
import random
profiles = _profiles()
profile = profiles.get(profile_uuid)
if not profile:
return make_response("Profile not found", 404)
from changedetectionio.notification_profiles.registry import registry
type_handler = registry.get(profile.get('type', 'apprise'))
# Pick a random watch for context variables
watch_uuid = request.form.get('watch_uuid')
if not watch_uuid and datastore.data.get('watching'):
watch_uuid = random.choice(list(datastore.data['watching'].keys()))
if not watch_uuid:
return make_response("Error: No watches configured for test notification", 400)
watch = datastore.data['watching'].get(watch_uuid)
prev_snapshot = "Example text: example test\nExample text: change detection is cool\n"
current_snapshot = "Example text: example test\nExample text: change detection is fantastic\n"
dates = list(watch.history.keys()) if watch else []
if len(dates) > 1:
prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2])
current_snapshot = watch.get_history_snapshot(timestamp=dates[-1])
n_object = NotificationContextData({'watch_url': watch.get('url', 'https://example.com') if watch else 'https://example.com'})
n_object.update(set_basic_notification_vars(
current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text='',
timestamp_changed=dates[-1] if dates else None,
))
from changedetectionio.notification_profiles.log import write_profile_log
try:
type_handler.send(profile.get('config', {}), n_object, datastore)
write_profile_log(datastore.datastore_path, profile_uuid,
watch_url=watch.get('url', '') if watch else '',
watch_uuid=watch_uuid or '',
status='test', message='Manual test')
except Exception as e:
logger.error(f"Test notification failed for profile {profile_uuid}: {e}")
write_profile_log(datastore.datastore_path, profile_uuid,
watch_url=watch.get('url', '') if watch else '',
watch_uuid=watch_uuid or '',
status='error', message=str(e))
return make_response(str(e), 400)
return 'OK - Test notification sent'
@bp.route("/<uuid_str:profile_uuid>/log", methods=['GET'])
@login_optionally_required
def profile_log(profile_uuid):
"""Show per-profile send history."""
from changedetectionio.notification_profiles.log import read_profile_log
profiles = _profiles()
profile = profiles.get(profile_uuid)
if not profile:
flash(gettext("Profile not found."), 'error')
return redirect(url_for('notification_profiles.index'))
entries = read_profile_log(datastore.datastore_path, profile_uuid)
return render_template('notification_profiles/log.html',
profile=profile,
entries=entries,
profile_uuid=profile_uuid)
return bp
def _extract_config(form_data, profile_type: str) -> dict:
"""Extract type-specific config fields from form POST data."""
if profile_type == 'apprise':
raw = form_data.get('notification_urls', '')
urls = [u.strip() for u in raw.splitlines() if u.strip()]
return {
'notification_urls': urls,
'notification_title': form_data.get('notification_title', '').strip() or None,
'notification_body': form_data.get('notification_body', '').strip() or None,
'notification_format': form_data.get('notification_format', '').strip() or None,
}
# Other types: plugins populate their own config keys
return dict(form_data)

View File

@@ -0,0 +1,36 @@
from wtforms import Form, StringField, TextAreaField, HiddenField, SubmitField, validators, ValidationError
from wtforms.fields import SelectField
from flask_babel import lazy_gettext as _l
from changedetectionio.notification import valid_notification_formats
class ValidateNotificationBodyAndTitleWhenURLisSet:
"""When notification URLs are provided, title and body must also be set."""
def __call__(self, form, field):
urls = [u.strip() for u in (field.data or '').splitlines() if u.strip()]
if urls:
if not (form.notification_title.data or '').strip():
raise ValidationError(_l('Notification Title is required when Notification URLs are set'))
if not (form.notification_body.data or '').strip():
raise ValidationError(_l('Notification Body is required when Notification URLs are set'))
class NotificationProfileForm(Form):
name = StringField(_l('Profile name'), [validators.InputRequired()])
profile_type = HiddenField(default='apprise')
save_button = SubmitField(_l('Save'), render_kw={"class": "pure-button pure-button-primary"})
# Apprise-type config fields
notification_urls = TextAreaField(
_l('Notification URL list'),
validators=[validators.Optional(), ValidateNotificationBodyAndTitleWhenURLisSet()],
render_kw={"rows": 5, "placeholder": "one URL per line\ne.g. mailtos://user:pass@smtp.example.com?to=you@example.com"},
)
notification_title = StringField(_l('Notification title'), validators=[validators.Optional()])
notification_body = TextAreaField(_l('Notification body'), validators=[validators.Optional()], render_kw={"rows": 5})
notification_format = SelectField(
_l('Notification format'),
choices=[(k, v) for k, v in valid_notification_formats.items() if k != 'System default'],
)

View File

@@ -0,0 +1,99 @@
{% extends 'base.html' %}
{% from '_helpers.html' import render_field %}
{% block content %}
<script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script>
<div class="pure-g">
<div class="pure-u-1">
<h1>{% if profile_uuid %}{{ _('Edit Notification Profile') }}{% else %}{{ _('New Notification Profile') }}{% endif %}</h1>
<form class="pure-form pure-form-stacked" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{{ form.profile_type() }}
<fieldset>
<div class="pure-control-group">
{{ render_field(form.name, placeholder=_('e.g. My Slack Alerts')) }}
</div>
{# Type selector — only one type for now but ready for more #}
<div class="pure-control-group" id="profile-type-selector">
<label>{{ _('Profile type') }}</label>
<div class="profile-type-cards">
{% for type_id, display_name in registry.choices() %}
{% set handler = registry.get(type_id) %}
<label class="profile-type-card {% if (existing.get('type') or 'apprise') == type_id %}active{% endif %}"
data-type="{{ type_id }}">
<input type="radio" name="_profile_type_radio" value="{{ type_id }}"
{% if (existing.get('type') or 'apprise') == type_id %}checked{% endif %}>
<i data-feather="{{ handler.icon }}"></i>
<span>{{ display_name }}</span>
</label>
{% endfor %}
</div>
</div>
{# Type-specific config — rendered via the type's template partial #}
<div id="profile-type-config">
{% for type_id, display_label in registry.choices() %}
{% set handler = registry.get(type_id) %}
<div class="profile-type-fields" data-type="{{ type_id }}"
style="{% if (existing.get('type') or 'apprise') != type_id %}display:none{% endif %}">
{% include handler.template %}
</div>
{% endfor %}
</div>
</fieldset>
<div class="pure-controls" style="margin-top: 1em; display: flex; gap: 8px; align-items: center;">
{{ render_field(form.save_button) }}
{% if profile_uuid %}
<a id="send-test-notification"
data-url="{{ url_for('notification_profiles.test', profile_uuid=profile_uuid) }}"
class="pure-button button-secondary">{{ _('Send test') }}</a>
<div class="spinner" style="display:none;"></div>
<span id="notification-test-log" style="display:none; font-size:0.85em;"></span>
{% endif %}
<a href="{{ url_for('notification_profiles.index') }}" class="pure-button">{{ _('Cancel') }}</a>
</div>
</form>
</div>
</div>
<script>
// Switch visible type config when card is clicked
document.querySelectorAll('.profile-type-card').forEach(function(card) {
card.addEventListener('click', function() {
var type = this.dataset.type;
document.querySelector('input[name="profile_type"]').value = type;
document.querySelectorAll('.profile-type-card').forEach(c => c.classList.remove('active'));
this.classList.add('active');
document.querySelectorAll('.profile-type-fields').forEach(function(el) {
el.style.display = el.dataset.type === type ? '' : 'none';
});
});
});
{% if profile_uuid %}
document.getElementById('send-test-notification').addEventListener('click', function(e) {
e.preventDefault();
var btn = this;
var log = document.getElementById('notification-test-log');
var spinner = document.querySelector('.spinner');
spinner.style.display = 'inline-block';
log.style.display = 'inline';
log.textContent = '{{ _("Sending...") }}';
fetch(btn.dataset.url, {
method: 'POST',
headers: {'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value},
}).then(r => r.text()).then(function(t) {
log.textContent = t;
}).catch(function(e) {
log.textContent = 'Error: ' + e;
}).finally(function() {
spinner.style.display = 'none';
});
});
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,115 @@
{% extends 'base.html' %}
{% block content %}
<div class="pure-g">
<div class="pure-u-1">
<h1>{{ _('Notification Profiles') }}</h1>
<p class="pure-form-message-inline">
{{ _('Profiles define where and how notifications are sent. Link them to watches or groups.') }}
<a href="{{ url_for('notification_profiles.edit') }}">{{ _('Create new profile') }}</a>
</p>
{% if not profiles %}
<div class="inline-warning" style="margin: 1em 0;">
{{ _('No notification profiles yet.') }}
<a href="{{ url_for('notification_profiles.edit') }}" class="pure-button pure-button-primary button-xsmall">{{ _('Create first profile') }}</a>
</div>
{% else %}
<table class="pure-table pure-table-striped" style="width:100%">
<thead>
<tr>
<th>{{ _('Name') }}</th>
<th>{{ _('Type') }}</th>
<th>{{ _('Destination') }}</th>
<th>{{ _('Used by') }}</th>
<th>{{ _('Last result') }}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for uuid, profile in profiles.items() %}
{% set type_handler = registry.get(profile.get('type', 'apprise')) %}
<tr>
<td><strong>{{ profile.get('name', '') }}</strong></td>
<td>
<i data-feather="{{ type_handler.icon }}" style="width:14px;height:14px;"></i>
{{ type_handler.display_name }}
</td>
<td><small class="pure-form-message-inline">{{ type_handler.get_url_hint(profile.get('config', {})) }}</small></td>
<td>
{% set n = usage.get(uuid, 0) %}
{% if n %}
<span class="button-tag button-xsmall">{{ n }} {{ _('watch(es)') }}</span>
{% else %}
<span style="color: var(--color-grey-600)">{{ _('unused') }}</span>
{% endif %}
</td>
<td>
{%- set _last = last_log.get(uuid) -%}
{%- if _last -%}
<a href="{{ url_for('notification_profiles.profile_log', profile_uuid=uuid) }}"
class="notif-last-result {{ _last.status }}"
title="{{ _last.ts }}{% if _last.message %} — {{ _last.message[:120] }}{% endif %}">
{%- if _last.status == 'ok' -%}&#10003; {{ _('OK') }}
{%- elif _last.status == 'test' -%}&#9654; {{ _('Test') }}
{%- else -%}&#10007; {{ _('Error') }}
{%- endif -%}
</a>
{%- else -%}
<a href="{{ url_for('notification_profiles.profile_log', profile_uuid=uuid) }}"
style="color:var(--color-grey-500); font-size:0.85em;">{{ _('no log') }}</a>
{%- endif -%}
</td>
<td style="white-space:nowrap;">
<a href="{{ url_for('notification_profiles.edit', profile_uuid=uuid) }}" class="pure-button button-xsmall">{{ _('Edit') }}</a>
<button type="button"
class="pure-button button-xsmall btn-test-profile"
data-test-url="{{ url_for('notification_profiles.test', profile_uuid=uuid) }}"
data-csrf="{{ csrf_token() }}">{{ _('Test') }}</button>
<span class="test-result-{{ uuid }} pure-form-message-inline" style="display:none; font-size:0.85em;"></span>
<form method="POST" action="{{ url_for('notification_profiles.delete', profile_uuid=uuid) }}" style="display:inline;"
onsubmit="return confirm('{{ _('Delete this profile?') }}{% if usage.get(uuid, 0) %} {{ _('It is linked to %(n)d watch(es).', n=usage.get(uuid,0)) }}{% endif %}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pure-button button-xsmall" style="background:var(--color-background-button-red);color:#fff;">{{ _('Delete') }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<br>
<a href="{{ url_for('notification_profiles.edit') }}" class="pure-button pure-button-primary">{{ _('+ New profile') }}</a>
</div>
</div>
<script>
document.querySelectorAll('.btn-test-profile').forEach(function(btn) {
btn.addEventListener('click', function() {
var url = this.dataset.testUrl;
var csrf = this.dataset.csrf;
var row = this.closest('tr');
var resultSpan = row.querySelector('[class^="test-result-"]');
btn.disabled = true;
btn.textContent = '...';
if (resultSpan) {
resultSpan.style.display = 'inline';
resultSpan.textContent = '{{ _("Sending...") }}';
}
fetch(url, {
method: 'POST',
headers: {'X-CSRFToken': csrf, 'Content-Type': 'application/x-www-form-urlencoded'},
}).then(function(r) {
return r.text().then(function(t) { return {ok: r.ok, text: t}; });
}).then(function(res) {
if (resultSpan) resultSpan.textContent = res.text;
}).catch(function(e) {
if (resultSpan) resultSpan.textContent = 'Error: ' + e;
}).finally(function() {
btn.disabled = false;
btn.textContent = '{{ _("Test") }}';
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends 'base.html' %}
{% block content %}
<div class="pure-g">
<div class="pure-u-1">
<h1>{{ _('Notification Log') }}: <em>{{ profile.get('name', '') }}</em></h1>
<p>
<a href="{{ url_for('notification_profiles.index') }}" class="pure-button button-xsmall">&#8592; {{ _('Back to profiles') }}</a>
<a href="{{ url_for('notification_profiles.edit', profile_uuid=profile_uuid) }}" class="pure-button button-xsmall">{{ _('Edit profile') }}</a>
</p>
{% if not entries %}
<div class="inline-warning" style="margin:1em 0;">
{{ _('No log entries yet — logs are written each time a notification is attempted.') }}
</div>
{% else %}
<table class="pure-table pure-table-striped" style="width:100%; font-size:0.88em;">
<thead>
<tr>
<th style="width:12em;">{{ _('Time') }}</th>
<th style="width:6em;">{{ _('Status') }}</th>
<th>{{ _('Watch') }}</th>
<th>{{ _('Detail') }}</th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr class="log-row-{{ e.status }}">
<td style="white-space:nowrap; font-family:monospace;">{{ e.ts }}</td>
<td>
{% if e.status == 'ok' %}
<span class="notif-log-status ok">&#10003; {{ _('OK') }}</span>
{% elif e.status == 'test' %}
<span class="notif-log-status test">&#9654; {{ _('Test') }}</span>
{% else %}
<span class="notif-log-status error">&#10007; {{ _('Error') }}</span>
{% endif %}
</td>
<td>
{% if e.watch_url %}
<a href="{{ e.watch_url | safe }}" target="_blank" rel="noopener" title="{{ e.watch_uuid }}">{{ e.watch_url[:80] }}{% if e.watch_url|length > 80 %}…{% endif %}</a>
{% else %}
<span style="color:var(--color-grey-500)"></span>
{% endif %}
</td>
<td>
{% if e.message %}
<small style="color: {% if e.status == 'error' %}var(--color-error){% else %}inherit{% endif %};">{{ e.message }}</small>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="pure-form-message-inline" style="margin-top:0.5em;">{{ _('Showing last %(n)d entries (newest first).', n=entries|length) }}</p>
{% endif %}
</div>
</div>
<style>
.notif-log-status { font-weight: bold; }
.notif-log-status.ok { color: var(--color-success, #2a9d2a); }
.notif-log-status.test { color: var(--color-info, #1a6fa8); }
.notif-log-status.error { color: var(--color-error, #c0392b); }
.log-row-error td { background: rgba(192,57,43,0.05); }
</style>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% from '_helpers.html' import render_field %}
{% from '_common_fields.html' import show_token_placeholders %}
<div class="pure-control-group">
{{ render_field(form.notification_urls,
rows=5,
placeholder="Examples:\n Slack - slack://TokenA/TokenB/TokenC\n Discord - discord://WebhookID/WebhookToken\n Email - mailtos://user:pass@smtp.host?to=you@example.com\n Telegram- tgram://BotToken/ChatID",
class="notification-urls") }}
<div class="pure-form-message-inline">
<strong>{{ _('Tip:') }}</strong> {{ _('Use') }}
<a target="newwindow" href="https://github.com/caronc/apprise">{{ _('Apprise Notification URLs') }}</a>
{{ _('for notifications to almost any service.') }}
<a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">{{ _('Configuration notes') }}</a>
</div>
<div class="notifications-wrapper" style="margin-top: 6px;">
<div class="spinner" style="display: none;"></div>
<div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">{{ _('Processing..') }}</span></div>
</div>
</div>
<div class="pure-control-group grey-form-border">
<div class="pure-control-group">
{{ render_field(form.notification_title,
class="notification-title",
placeholder=_('Leave blank to use system default')) }}
</div>
<div class="pure-control-group">
{{ render_field(form.notification_body,
rows=5,
class="notification-body",
placeholder=_('Leave blank to use system default')) }}
{{ show_token_placeholders(extra_notification_token_placeholder_info=None) }}
</div>
<div class="pure-control-group">
{{ render_field(form.notification_format, class="notification-format") }}
</div>
</div>

View File

@@ -3,7 +3,7 @@ Utility functions for RSS feed generation.
"""
from changedetectionio.notification.handler import process_notification
from changedetectionio.notification_service import NotificationContextData, _check_cascading_vars
from changedetectionio.notification_service import NotificationContextData
from loguru import logger
import datetime
import pytz
@@ -71,7 +71,14 @@ def validate_rss_token(datastore, request):
def get_rss_template(datastore, watch, rss_content_format, default_html, default_plaintext):
"""Get the appropriate template for RSS content."""
if datastore.data['settings']['application'].get('rss_template_type') == 'notification_body':
return _check_cascading_vars(datastore=datastore, var_name='notification_body', watch=watch)
# Resolve notification body from the profile chain (watch → tag → system)
from changedetectionio.notification_profiles.resolver import resolve_notification_profiles
from changedetectionio.notification import default_notification_body
for profile, _ in resolve_notification_profiles(watch, datastore):
body = profile.get('config', {}).get('notification_body')
if body:
return body
return default_notification_body
override = datastore.data['settings']['application'].get('rss_template_override')
if override and override.strip():

View File

@@ -45,9 +45,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
extra_notification_tokens=datastore.get_unique_notification_tokens_available()
)
# Remove the last option 'System default'
form.application.form.notification_format.choices.pop()
if datastore.proxy_list is None:
# @todo - Couldn't get setattr() etc dynamic addition working, so remove it instead
del form.requests.form.proxy
@@ -75,6 +72,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
del (app_update['password'])
datastore.data['settings']['application'].update(app_update)
# notification_profiles is submitted as hidden inputs (list of UUIDs), not a form field
datastore.data['settings']['application']['notification_profiles'] = request.form.getlist('notification_profiles')
# Handle dynamic worker count adjustment
old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
@@ -164,6 +163,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Instantiate the form with existing settings
plugin_forms[plugin_id] = form_class(data=settings)
from changedetectionio.notification_profiles.registry import registry as notification_registry
output = render_template("settings.html",
active_plugins=active_plugins,
api_key=datastore.data['settings']['application'].get('api_access_token'),
@@ -175,6 +175,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
form=form,
hide_remove_pass=os.getenv("SALTED_PASS", False),
min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)),
notification_registry=notification_registry,
settings_application=datastore.data['settings']['application'],
timezone_default_config=datastore.data['settings']['application'].get('scheduler_timezone_default'),
utc_time=utc_time,

View File

@@ -103,7 +103,18 @@
<div class="tab-pane-inner" id="notifications">
<fieldset>
{{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
<div class="pure-control-group">
<label>{{ _('System-wide notification profiles') }}</label>
<p class="pure-form-message-inline">{{ _('Profiles linked here fire for every watch that has no profiles of its own (or its groups).') }}
<a href="{{ url_for('notification_profiles.index') }}">{{ _('Manage profiles →') }}</a></p>
{% from '_notification_profiles_selector.html' import render_notification_profile_selector %}
{{ render_notification_profile_selector(
own_profiles=settings_application.get('notification_profiles', []),
inherited_profiles=[],
all_profile_data=settings_application.get('notification_profile_data', {}),
registry=notification_registry
) }}
</div>
</fieldset>
<div class="pure-control-group" id="notification-base-url">
{{ render_field(form.application.form.base_url, class="m-d") }}
@@ -154,9 +165,8 @@
</span>
</div>
<div class="pure-control-group">
<br>
{{ _('Tip:') }} <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">{{ _('Connect using Bright Data and Oxylabs Proxies, find out more here.') }}</a>
<br>
{{ _('Tip:') }} <a href="{{ url_for('settings.settings_page')}}#proxies">{{ _('Connect using Bright Data proxies, find out more here.') }}</a>
</div>
</div>
@@ -352,7 +362,7 @@ nav
</div>
</div>
<p><strong>{{ _('Tip') }}</strong>: {{ _('"Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites.') }}</p>
<p><strong>{{ _('Tip') }}</strong>: {{ _('"Residential" and "Mobile" proxy type can be more successful than "Data Center" for blocked websites.') }}</p>
<div class="pure-control-group" id="extra-proxies-setting">
{{ render_fieldlist_with_inline_errors(form.requests.form.extra_proxies) }}

View File

@@ -7,6 +7,14 @@ from changedetectionio.store import ChangeDetectionStore
from changedetectionio.flask_app import login_optionally_required
def _get_tag_inherited_notification_profiles(datastore):
"""Tags only inherit from system level."""
result = []
for uid in datastore.data['settings']['application'].get('notification_profiles', []):
result.append((uid, 'system'))
return result
def construct_blueprint(datastore: ChangeDetectionStore):
tags_blueprint = Blueprint('tags', __name__, template_folder="templates")
@@ -175,11 +183,14 @@ def construct_blueprint(datastore: ChangeDetectionStore):
sub_field.data = sub_value
break
from changedetectionio.notification_profiles.registry import registry as notification_registry
template_args = {
'data': default,
'form': form,
'watch': default,
'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
'notification_registry': notification_registry,
'inherited_notification_profiles': _get_tag_inherited_notification_profiles(datastore),
}
included_content = {}
@@ -239,6 +250,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
tag.update(form.data)
tag['processor'] = 'restock_diff'
tag['notification_profiles'] = request.form.getlist('notification_profiles')
tag.commit()
# Clear checksums for all watches using this tag to force reprocessing

View File

@@ -63,27 +63,17 @@
{% endif %}
<div class="tab-pane-inner" id="notifications">
<fieldset>
<div class="pure-control-group inline-radio">
<div class="pure-control-group inline-radio">
{{ render_ternary_field(form.notification_muted, BooleanField=True) }}
</div>
{% if 1 %}
<div class="pure-control-group inline-radio">
{{ render_checkbox_field(form.notification_screenshot) }}
<span class="pure-form-message-inline">
<strong>{{ _('Use with caution!') }}</strong> {{ _('This will easily fill up your email storage quota or flood other storages.') }}
</span>
</div>
{% endif %}
<div class="field-group" id="notification-field-group">
{% if has_default_notification_urls %}
<div class="inline-warning">
<img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="{{ _('Look out!') }}" title="{{ _('Lookout!') }}" >
{{ _('There are') }} <a href="{{ url_for('settings.settings_page')}}#notifications">{{ _('system-wide notification URLs enabled') }}</a>, {{ _('this form will override notification settings for this watch only') }} &dash; {{ _('an empty Notification URL list here will still send notifications.') }}
</div>
{% endif %}
<a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">{{ _('Use system defaults') }}</a>
{{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
<div class="pure-control-group">
{% from '_notification_profiles_selector.html' import render_notification_profile_selector %}
{{ render_notification_profile_selector(
own_profiles=watch.get('notification_profiles', []),
inherited_profiles=inherited_notification_profiles,
all_profile_data=settings_application.get('notification_profile_data', {}),
registry=notification_registry
) }}
</div>
</fieldset>
</div>

View File

@@ -83,15 +83,10 @@ def _handle_operations(op, uuids, datastore, worker_pool, update_q, queuedWatchM
flash(gettext("{} watches cleared/reset.").format(len(uuids)))
elif (op == 'notification-default'):
from changedetectionio.notification import (
USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
)
for uuid in uuids:
if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid]['notification_title'] = None
datastore.data['watching'][uuid]['notification_body'] = None
datastore.data['watching'][uuid]['notification_urls'] = []
datastore.data['watching'][uuid]['notification_format'] = USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
# Clear watch-level profile overrides so the watch falls back to tag/system profiles
datastore.data['watching'][uuid]['notification_profiles'] = []
datastore.data['watching'][uuid].commit()
if emit_flash:
flash(gettext("{} watches set to use default notification settings").format(len(uuids)))

View File

@@ -11,9 +11,27 @@ from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio.time_handler import is_within_schedule
from changedetectionio import worker_pool
def _get_inherited_notification_profiles(watch, datastore):
"""Return list of (uuid, origin_label) for profiles inherited from groups/system."""
own = set(watch.get('notification_profiles', []))
result = []
seen = set()
tags = datastore.get_all_tags_for_watch(uuid=watch.get('uuid')) or {}
for tag in tags.values():
for uid in tag.get('notification_profiles', []):
if uid not in own and uid not in seen:
result.append((uid, tag.get('title', 'group')))
seen.add(uid)
for uid in datastore.data['settings']['application'].get('notification_profiles', []):
if uid not in own and uid not in seen:
result.append((uid, 'system'))
seen.add(uid)
return result
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates")
def _watch_has_tag_options_set(watch):
"""This should be fixed better so that Tag is some proper Model, a tag is just a Watch also"""
for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
@@ -200,6 +218,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
tag_uuids.append(datastore.add_tag(title=t))
extra_update_obj['tags'] = tag_uuids
# notification_profiles comes from hidden inputs (not a form field), handle separately
extra_update_obj['notification_profiles'] = request.form.getlist('notification_profiles')
datastore.data['watching'][uuid].update(form.data)
datastore.data['watching'][uuid].update(extra_update_obj)
@@ -303,7 +324,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
'extra_processor_config': form.extra_tab_content(),
'extra_title': f" - Edit - {watch.label}",
'form': form,
'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False,
'inherited_notification_profiles': _get_inherited_notification_profiles(watch, datastore),
'notification_registry': __import__('changedetectionio.notification_profiles.registry', fromlist=['registry']).registry,
'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
'has_special_tag_options': _watch_has_tag_options_set(watch=watch),
'jq_support': jq_support,

View File

@@ -7,7 +7,7 @@ from changedetectionio.auth_decorator import login_optionally_required
def construct_blueprint(datastore: ChangeDetectionStore):
notification_blueprint = Blueprint('ui_notification', __name__, template_folder="../ui/templates")
# 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'])
@@ -15,12 +15,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
@login_optionally_required
def ajax_callback_send_notification_test(watch_uuid=None):
from changedetectionio.notification_service import NotificationContextData, set_basic_notification_vars
# 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 changedetectionio.notification.apprise_plugin.assets import apprise_asset
from changedetectionio.jinja2_custom import render as jinja_render
from changedetectionio.notification.apprise_plugin.custom_handlers import apprise_http_custom_handler
apobj = apprise.Apprise(asset=apprise_asset)
@@ -38,69 +36,112 @@ def construct_blueprint(datastore: ChangeDetectionStore):
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 = request.form.get('notification_urls','').strip().splitlines()
notification_urls = [u for u in request.form.get('notification_urls', '').strip().splitlines() if u.strip()]
# --- Profile-based path: no inline URLs provided, use resolved profiles for the watch ---
if not notification_urls and watch_uuid and not is_global_settings_form and not is_group_settings_form:
from changedetectionio.notification_profiles.resolver import resolve_notification_profiles
if watch:
profiles = resolve_notification_profiles(watch, datastore)
if not profiles:
return make_response('Error: No notification profiles are linked to this watch (check watch, tags, and system settings)', 400)
prev_snapshot = "Example text: example test\nExample text: change detection is cool\nExample text: some more examples\n"
current_snapshot = "Example text: example test\nExample text: change detection is fantastic\nExample text: even more examples\nExample text: a lot more examples"
dates = list(watch.history.keys())
if len(dates) > 1:
prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2])
current_snapshot = watch.get_history_snapshot(timestamp=dates[-1])
errors = []
sent = 0
for profile, type_handler in profiles:
n_object = NotificationContextData({'watch_url': watch.get('url', 'https://example.com')})
n_object.update(set_basic_notification_vars(
current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text='',
timestamp_changed=dates[-1] if dates else None,
))
try:
type_handler.send(profile.get('config', {}), n_object, datastore)
sent += 1
except Exception as e:
logger.error(f"Test notification profile '{profile.get('name')}' failed: {e}")
errors.append(f"{profile.get('name', '?')}: {e}")
if errors:
return make_response('; '.join(errors), 400)
return f'OK - Sent test via {sent} profile(s)'
# --- Legacy path: notification_urls supplied via form (global/group settings test) ---
if not notification_urls:
logger.debug("Test notification - Trying by group/tag in the edit form if available")
# 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(','):
tag = datastore.tag_exists_by_name(k.strip())
notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None
if is_global_settings_form or is_group_settings_form:
# Try system-level profiles
from changedetectionio.notification_profiles.resolver import resolve_notification_profiles
if watch:
profiles = resolve_notification_profiles(watch, datastore)
if profiles:
prev_snapshot = "Example text: example test\nExample text: change detection is cool\n"
current_snapshot = "Example text: example test\nExample text: change detection is fantastic\n"
dates = list(watch.history.keys())
if len(dates) > 1:
prev_snapshot = watch.get_history_snapshot(timestamp=dates[-2])
current_snapshot = watch.get_history_snapshot(timestamp=dates[-1])
if not notification_urls and not is_global_settings_form and not is_group_settings_form:
# In the global settings, use only what is typed currently in the text box
logger.debug("Test notification - Trying by global system settings notifications")
if datastore.data['settings']['application'].get('notification_urls'):
notification_urls = datastore.data['settings']['application']['notification_urls']
errors = []
sent = 0
for profile, type_handler in profiles:
n_object = NotificationContextData({'watch_url': watch.get('url', 'https://example.com')})
n_object.update(set_basic_notification_vars(
current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text='',
timestamp_changed=dates[-1] if dates else None,
))
try:
type_handler.send(profile.get('config', {}), n_object, datastore)
sent += 1
except Exception as e:
errors.append(f"{profile.get('name', '?')}: {e}")
if errors:
return make_response('; '.join(errors), 400)
return f'OK - Sent test via {sent} profile(s)'
if not notification_urls:
return 'Error: No Notification URLs set/found'
return make_response('Error: No notification profiles or URLs configured', 400)
# Validate apprise URLs
for n_url in notification_urls:
# We are ONLY validating the apprise:// part here, convert all tags to something so as not to break apprise URLs
generic_notification_context_data = NotificationContextData()
generic_notification_context_data.set_random_for_validation()
n_url = jinja_render(template_str=n_url, **generic_notification_context_data).strip()
if len(n_url.strip()):
if not apobj.add(n_url):
return f'Error: {n_url} is not a valid AppRise URL.'
n_url_rendered = jinja_render(template_str=n_url, **generic_notification_context_data).strip()
if n_url_rendered and not apobj.add(n_url_rendered):
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 = NotificationContextData({
'watch_url': request.form.get('window_url', "https://changedetection.io"),
'notification_urls': notification_urls
})
# 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()
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()
elif datastore.data['settings']['application'].get('notification_title'):
n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title')
else:
n_object['notification_title'] = "Test title"
if 'notification_body' in request.form and request.form['notification_body'].strip():
n_object['notification_body'] = request.form.get('notification_body', '').strip()
elif datastore.data['settings']['application'].get('notification_body'):
n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')
else:
n_object['notification_body'] = "Test body"
n_object['as_async'] = False
# Same like in notification service, should be refactored
dates = list(watch.history.keys())
trigger_text = ''
snapshot_contents = ''
# Could be called as a 'test notification' with only 1 snapshot available
dates = list(watch.history.keys()) if watch else []
prev_snapshot = "Example text: example test\nExample text: change detection is cool\nExample text: some more examples\n"
current_snapshot = "Example text: example test\nExample text: change detection is fantastic\nExample text: even more examples\nExample text: a lot more examples"
@@ -111,22 +152,19 @@ def construct_blueprint(datastore: ChangeDetectionStore):
n_object.update(set_basic_notification_vars(current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text=trigger_text,
triggered_text='',
timestamp_changed=dates[-1] if dates else None))
sent_obj = process_notification(n_object, datastore)
except Exception as e:
logger.error(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'
return notification_blueprint
return notification_blueprint

View File

@@ -273,7 +273,7 @@ Math: {{ 1 + 1 }}") }}
<div class="tab-pane-inner" id="notifications">
<fieldset>
<div class="pure-control-group inline-radio">
<div class="pure-control-group inline-radio">
{{ render_ternary_field(form.notification_muted, BooleanField=true) }}
</div>
{% if capabilities.supports_screenshots %}
@@ -284,15 +284,21 @@ Math: {{ 1 + 1 }}") }}
</span>
</div>
{% endif %}
<div class="field-group" id="notification-field-group">
{% if has_default_notification_urls %}
<div class="inline-warning">
<img class="inline-warning-icon" src="{{url_for('static_content', group='images', filename='notice.svg')}}" alt="{{ _('Look out!') }}" title="{{ _('Lookout!') }}" >
{{ _('There are') }} <a href="{{ url_for('settings.settings_page')}}#notifications">{{ _('system-wide notification URLs enabled') }}</a>, {{ _('this form will override notification settings for this watch only') }} &dash; {{ _('an empty Notification URL list here will still send notifications.') }}
<div class="pure-control-group">
{% from '_notification_profiles_selector.html' import render_notification_profile_selector %}
{{ render_notification_profile_selector(
own_profiles=watch.get('notification_profiles', []),
inherited_profiles=inherited_notification_profiles,
all_profile_data=settings_application.get('notification_profile_data', {}),
registry=notification_registry
) }}
</div>
<div class="pure-control-group" style="margin-top: 1em;">
<button id="send-test-notification" type="button" class="pure-button button-secondary">{{ _('Send test notification') }}</button>
<div class="notifications-wrapper" style="display:inline-block; margin-left: 8px;">
<div class="spinner" style="display: none;"></div>
<div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline"></span></div>
</div>
{% endif %}
<a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">{{ _('Use system defaults') }}</a>
{{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
</div>
</fieldset>
</div>

View File

@@ -279,9 +279,19 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
{%- endif -%}
{%- endif -%}
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
{%- set _watch_tags = datastore.get_all_tags_for_watch(watch['uuid']) -%}
{%- for watch_tag_uuid, watch_tag in _watch_tags.items() -%}
<a href="{{url_for('watchlist.index', tag=watch_tag_uuid) }}" class="watch-tag-list tag-{{ watch_tag.title|sanitize_tag_class }}">{{ watch_tag.title }}</a>
{%- endfor -%}
{%- for np in get_resolved_notification_profiles(watch) -%}
{%- if np.level == 'direct' -%}
<span class="watch-notif-profile" title="{{ _('Direct notification profile') }}"><i data-feather="bell" style="width:10px;height:10px;vertical-align:middle;"></i> {{ np.name }}</span>
{%- elif np.level == 'group' -%}
<span class="watch-notif-profile inherited" title="{{ _('Via group') }}: {{ np.group_name }}"><i data-feather="bell" style="width:10px;height:10px;vertical-align:middle;"></i> {{ np.name }}</span>
{%- else -%}
<span class="watch-notif-profile system" title="{{ _('System-wide profile') }}"><i data-feather="bell" style="width:10px;height:10px;vertical-align:middle;"></i> {{ np.name }}</span>
{%- endif -%}
{%- endfor -%}
</div>
<div class="status-icons">
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a>

View File

@@ -240,6 +240,31 @@ def _get_current_worker_count():
"""Get the current number of operational workers"""
return worker_pool.get_worker_count()
@app.template_global('get_resolved_notification_profiles')
def _get_resolved_notification_profiles(watch):
"""Return list of resolved notification profile info dicts for a watch.
Each entry: {'name': str, 'level': 'direct'|'group'|'system', 'group_name': str}
Deduplicated by UUID across all levels — same logic as resolve_notification_profiles().
"""
all_profiles = datastore.data['settings']['application'].get('notification_profile_data', {})
seen = set()
result = []
def _add(uuids, level, group_name=''):
for uid in (uuids or []):
if uid in seen or uid not in all_profiles:
continue
seen.add(uid)
result.append({'name': all_profiles[uid].get('name', ''), 'level': level, 'group_name': group_name})
_add(watch.get('notification_profiles', []), 'direct')
for tag in (datastore.get_all_tags_for_watch(uuid=watch.get('uuid')) or {}).values():
_add(tag.get('notification_profiles', []), 'group', tag.get('title', ''))
_add(datastore.data['settings']['application'].get('notification_profiles', []), 'system')
return result
@app.template_global('get_worker_status_info')
def _get_worker_status_info():
"""Get detailed worker status information for display"""
@@ -859,6 +884,9 @@ def changedetection_app(config=None, datastore_o=None):
import changedetectionio.blueprint.tags as tags
app.register_blueprint(tags.construct_blueprint(datastore), url_prefix='/tags')
from changedetectionio.blueprint.notification_profiles import construct_blueprint as construct_notification_profiles_blueprint
app.register_blueprint(construct_notification_profiles_blueprint(datastore), url_prefix='/notification-profiles')
import changedetectionio.blueprint.check_proxies as check_proxies
app.register_blueprint(check_proxies.construct_blueprint(datastore=datastore), url_prefix='/check_proxy')

View File

@@ -443,20 +443,6 @@ class ValidateContentFetcherIsReady(object):
# raise ValidationError(message % (field.data, e))
class ValidateNotificationBodyAndTitleWhenURLisSet(object):
"""
Validates that they entered something in both notification title+body when the URL is set
Due to https://github.com/dgtlmoon/changedetection.io/issues/360
"""
def __init__(self, message=None):
self.message = message
def __call__(self, form, field):
if len(field.data):
if not len(form.notification_title.data) or not len(form.notification_body.data):
message = field.gettext('Notification Body and Title is required when a Notification URL is used')
raise ValidationError(message)
class ValidateAppRiseServers(object):
"""
@@ -734,17 +720,7 @@ class quickWatchForm(Form):
class commonSettingsForm(Form):
from . import processors
def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs):
super().__init__(formdata, obj, prefix, data, meta, **kwargs)
self.notification_body.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
self.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
self.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
fetch_backend = RadioField(_l('Fetch Method'), choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()])
notification_body = TextAreaField(_l('Notification Body'), default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()])
notification_format = SelectField(_l('Notification format'), choices=list(valid_notification_formats.items()))
notification_title = StringField(_l('Notification Title'), default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
notification_urls = StringListField(_l('Notification URL List'), validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])
processor = RadioField( label=_l("Processor - What do you want to achieve?"), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
scheduler_timezone_default = StringField(_l("Default timezone for watch check scheduler"), render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
webdriver_delay = IntegerField(_l('Wait seconds before extracting text'), validators=[validators.Optional(), validators.NumberRange(min=1, message=_l("Should contain one or more seconds"))])
@@ -1040,12 +1016,6 @@ class globalSettingsForm(Form):
# Define these as FormFields/"sub forms", this way it matches the JSON storage
# datastore.data['settings']['application']..
# datastore.data['settings']['requests']..
def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs):
super().__init__(formdata, obj, prefix, data, meta, **kwargs)
self.application.notification_body.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
self.application.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
self.application.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {})
requests = FormField(globalSettingsRequestForm)
application = FormField(globalSettingsApplicationForm)
save_button = SubmitField(_l('Save'), render_kw={"class": "pure-button pure-button-primary"})

View File

@@ -51,10 +51,8 @@ class model(dict):
'ignore_whitespace': True,
'ignore_status_codes': False, #@todo implement, as ternary.
'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison
'notification_body': default_notification_body,
'notification_format': default_notification_format,
'notification_title': default_notification_title,
'notification_urls': [], # Apprise URL list
'notification_profile_data': {}, # uuid → NotificationProfile dict (the actual stored profiles)
'notification_profiles': [], # System-level linked NotificationProfile UUIDs
'pager_size': 50,
'password': False,
'render_anchor_tag_content': False,

View File

@@ -207,12 +207,9 @@ class watch_base(dict):
'last_viewed': 0, # history key value of the last viewed via the [diff] link
'method': 'GET',
'notification_alert_count': 0,
'notification_body': None,
'notification_format': USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH,
'notification_muted': False,
'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL
'notification_title': None,
'notification_urls': [], # List of URLs to add to the notification Queue (Usually AppRise)
'notification_profiles': [], # List of linked NotificationProfile UUIDs
'notification_screenshot': False, # Include the latest screenshot if available and supported by the apprise URL
'page_title': None, # <title> from the page
'paused': False,
'previous_md5': False,

View File

@@ -290,13 +290,29 @@ class NotificationService:
def __init__(self, datastore, notification_q):
self.datastore = datastore
self.notification_q = notification_q
def queue_notification_for_watch(self, n_object: NotificationContextData, watch, date_index_from=-2, date_index_to=-1):
"""
Queue a notification for a watch with full diff rendering and template variables
"""
from changedetectionio.notification import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
def _log_profile_send(self, profile: dict, watch=None, *, status: str, message: str = ''):
"""Write one entry to the per-profile notification log."""
try:
from changedetectionio.notification_profiles.log import write_profile_log
write_profile_log(
self.datastore.datastore_path,
profile_uuid=profile.get('uuid', ''),
watch_url=watch.get('url', '') if watch else '',
watch_uuid=watch.get('uuid', '') if watch else '',
status=status,
message=message,
)
except Exception as log_err:
logger.warning(f"Could not write profile log: {log_err}")
def queue_notification_for_watch(self, n_object: NotificationContextData, watch,
profile=None, type_handler=None,
date_index_from=-2, date_index_to=-1):
"""
Build full notification context and either queue it (via type_handler.send) or return it.
profile and type_handler come from resolve_notification_profiles().
"""
if not isinstance(n_object, NotificationContextData):
raise TypeError(f"Expected NotificationContextData, got {type(n_object)}")
@@ -308,17 +324,11 @@ class NotificationService:
dates = list(watch_history.keys())
trigger_text = watch.get('trigger_text', [])
# Add text that was triggered
if len(dates):
snapshot_contents = watch.get_history_snapshot(timestamp=dates[-1])
else:
snapshot_contents = "No snapshot/history available, the watch should fetch atleast once."
# If we ended up here with "System default"
if n_object.get('notification_format') == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:
n_object['notification_format'] = self.datastore.data['settings']['application'].get('notification_format')
triggered_text = ''
if len(trigger_text):
from . import html_tools
@@ -326,7 +336,6 @@ class NotificationService:
if triggered_text:
triggered_text = '\n'.join(triggered_text)
# Could be called as a 'test notification' with only 1 snapshot available
prev_snapshot = "Example text: example test\nExample text: change detection is cool\nExample text: some more examples\n"
current_snapshot = "Example text: example test\nExample text: change detection is fantastic\nExample text: even more examples\nExample text: a lot more examples"
@@ -334,14 +343,19 @@ class NotificationService:
prev_snapshot = watch.get_history_snapshot(timestamp=dates[date_index_from])
current_snapshot = watch.get_history_snapshot(timestamp=dates[date_index_to])
n_object.update(set_basic_notification_vars(current_snapshot=current_snapshot,
prev_snapshot=prev_snapshot,
watch=watch,
triggered_text=triggered_text,
timestamp_changed=dates[date_index_to]))
if self.notification_q:
# Include screenshot if configured on the watch
n_object['notification_screenshot'] = watch.get('notification_screenshot', False) if watch else False
if type_handler and profile:
logger.debug(f"Sending via profile type_handler {type_handler.type_id}")
type_handler.send(profile.get('config', {}), n_object, self.datastore)
elif self.notification_q:
logger.debug("Queued notification for sending")
self.notification_q.put(n_object)
else:
@@ -350,54 +364,83 @@ class NotificationService:
def send_content_changed_notification(self, watch_uuid):
"""
Send notification when content changes are detected
Send notification when content changes are detected.
Fires all NotificationProfiles linked to the watch (via watch → tags → system cascade).
"""
n_object = NotificationContextData()
from changedetectionio.model.resolver import resolve_setting
from changedetectionio.notification_profiles.resolver import resolve_notification_profiles
watch = self.datastore.data['watching'].get(watch_uuid)
if not watch:
return
return False
# Mute cascade: watch → tag → system
muted = resolve_setting(watch, self.datastore, 'notification_muted', sentinel_values={None}, default=False)
if muted:
return False
watch_history = watch.history
dates = list(watch_history.keys())
# Theoretically it's possible that this could be just 1 long,
# - In the case that the timestamp key was not unique
if len(dates) == 1:
raise ValueError(
"History index had 2 or more, but only 1 date loaded, timestamps were not unique? maybe two of the same timestamps got written, needs more delay?"
)
# Should be a better parent getter in the model object
profiles = resolve_notification_profiles(watch, self.datastore)
if not profiles:
return False
# Prefer - Individual watch settings > Tag settings > Global settings (in that order)
# this change probably not needed?
n_object['notification_urls'] = _check_cascading_vars(self.datastore, 'notification_urls', watch)
n_object['notification_title'] = _check_cascading_vars(self.datastore,'notification_title', watch)
n_object['notification_body'] = _check_cascading_vars(self.datastore,'notification_body', watch)
n_object['notification_format'] = _check_cascading_vars(self.datastore,'notification_format', watch)
# (Individual watch) Only prepare to notify if the rules above matched
queued = False
if n_object and n_object.get('notification_urls'):
queued = True
for profile, type_handler in profiles:
n_object = NotificationContextData()
try:
self.queue_notification_for_watch(n_object=n_object, watch=watch,
profile=profile, type_handler=type_handler)
queued = True
self._log_profile_send(profile, watch, status='ok')
except Exception as e:
err_str = str(e)
logger.error(f"Notification profile '{profile.get('name', profile.get('uuid'))}' failed for watch {watch_uuid}: {e}")
self._log_profile_send(profile, watch, status='error', message=err_str)
self.datastore.update_watch(uuid=watch_uuid,
update_obj={'last_notification_error': "Notification error detected, goto notification log."})
try:
from changedetectionio.flask_app import notification_debug_log
notification_debug_log += err_str.splitlines()
notification_debug_log[:] = notification_debug_log[-100:]
except Exception:
pass
if queued:
count = watch.get('notification_alert_count', 0) + 1
self.datastore.update_watch(uuid=watch_uuid, update_obj={'notification_alert_count': count})
self.queue_notification_for_watch(n_object=n_object, watch=watch)
return queued
def send_filter_failure_notification(self, watch_uuid):
"""
Send notification when CSS/XPath filters fail consecutively
Send notification when CSS/XPath filters fail consecutively.
Fires via the resolved notification profiles for the watch.
"""
from changedetectionio.notification_profiles.resolver import resolve_notification_profiles
from changedetectionio.model.resolver import resolve_setting
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts')
watch = self.datastore.data['watching'].get(watch_uuid)
if not watch:
return
# Mute cascade check
muted = resolve_setting(watch, self.datastore, 'notification_muted', sentinel_values={None}, default=False)
if muted:
return
profiles = resolve_notification_profiles(watch, self.datastore)
if not profiles:
logger.debug(f"NOT sending filter not found notification for {watch_uuid} - no notification profiles")
return
filter_list = ", ".join(watch['include_filters'])
# @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed
body = f"""Hello,
Your configured CSS/xPath filters of '{filter_list}' for {{{{watch_url}}}} did not appear on the page after {threshold} attempts.
@@ -408,47 +451,55 @@ Edit link: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}
Thanks - Your omniscient changedetection.io installation.
"""
n_object = NotificationContextData({
'notification_title': 'Changedetection.io - Alert - CSS/xPath filter was not present in the page',
'notification_body': body,
'notification_format': _check_cascading_vars(self.datastore, 'notification_format', watch),
'watch_url': watch['url'],
'uuid': watch_uuid,
'watch_uuid': watch_uuid,
'screenshot': None,
})
n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')
if len(watch['notification_urls']):
n_object['notification_urls'] = watch['notification_urls']
# Use the notification_format from the profile config, or fall back to system default
from changedetectionio.notification import default_notification_format
n_object['notification_format'] = default_notification_format
n_object['markup_text_links_to_html_links'] = n_object.get('notification_format', '').startswith('html')
elif len(self.datastore.data['settings']['application']['notification_urls']):
n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls']
for profile, type_handler in profiles:
try:
type_handler.send(profile.get('config', {}), n_object, self.datastore)
self._log_profile_send(profile, watch, status='ok', message='Filter failure alert')
except Exception as e:
logger.error(f"Filter failure notification via profile '{profile.get('name')}' failed: {e}")
self._log_profile_send(profile, watch, status='error', message=str(e))
# Only prepare to notify if the rules above matched
if 'notification_urls' in n_object:
n_object.update({
'watch_url': watch['url'],
'uuid': watch_uuid,
'screenshot': None
})
self.notification_q.put(n_object)
logger.debug(f"Sent filter not found notification for {watch_uuid}")
else:
logger.debug(f"NOT sending filter not found notification for {watch_uuid} - no notification URLs")
logger.debug(f"Sent filter not found notification for {watch_uuid} via {len(profiles)} profile(s)")
def send_step_failure_notification(self, watch_uuid, step_n):
"""
Send notification when browser steps fail consecutively
Send notification when browser steps fail consecutively.
Fires via the resolved notification profiles for the watch.
"""
from changedetectionio.notification_profiles.resolver import resolve_notification_profiles
from changedetectionio.model.resolver import resolve_setting
watch = self.datastore.data['watching'].get(watch_uuid, False)
if not watch:
return
muted = resolve_setting(watch, self.datastore, 'notification_muted', sentinel_values={None}, default=False)
if muted:
return
profiles = resolve_notification_profiles(watch, self.datastore)
if not profiles:
return
threshold = self.datastore.data['settings']['application'].get('filter_failure_notification_threshold_attempts')
step = step_n + 1
# @todo - This could be a markdown template on the disk, apprise will convert the markdown to HTML+Plaintext parts in the email, and then 'markup_text_links_to_html_links' is not needed
# {{{{ }}}} because this will be Jinja2 {{ }} tokens
body = f"""Hello,
Your configured browser step at position {step} for the web page watch {{{{watch_url}}}} did not appear on the page after {threshold} attempts, did the page change layout?
The element may have moved and needs editing, or does it need a delay added?
@@ -457,28 +508,26 @@ Edit link: {{{{base_url}}}}/edit/{{{{watch_uuid}}}}
Thanks - Your omniscient changedetection.io installation.
"""
from changedetectionio.notification import default_notification_format
n_object = NotificationContextData({
'notification_title': f"Changedetection.io - Alert - Browser step at position {step} could not be run",
'notification_body': body,
'notification_format': self._check_cascading_vars('notification_format', watch),
'notification_format': default_notification_format,
'watch_url': watch['url'],
'uuid': watch_uuid,
'watch_uuid': watch_uuid,
'screenshot': None,
})
n_object['markup_text_links_to_html_links'] = n_object.get('notification_format').startswith('html')
if len(watch['notification_urls']):
n_object['notification_urls'] = watch['notification_urls']
for profile, type_handler in profiles:
try:
type_handler.send(profile.get('config', {}), n_object, self.datastore)
self._log_profile_send(profile, watch, status='ok', message='Browser step failure alert')
except Exception as e:
logger.error(f"Step failure notification via profile '{profile.get('name')}' failed: {e}")
self._log_profile_send(profile, watch, status='error', message=str(e))
elif len(self.datastore.data['settings']['application']['notification_urls']):
n_object['notification_urls'] = self.datastore.data['settings']['application']['notification_urls']
# Only prepare to notify if the rules above matched
if 'notification_urls' in n_object:
n_object.update({
'watch_url': watch['url'],
'uuid': watch_uuid
})
self.notification_q.put(n_object)
logger.error(f"Sent step not found notification for {watch_uuid}")
logger.error(f"Sent step not found notification for {watch_uuid} via {len(profiles)} profile(s)")
# Convenience functions for creating notification service instances

View File

@@ -131,16 +131,18 @@ class difference_detection_processor():
await self.validate_iana_url()
# Requests, playwright, other browser via wss:// etc, fetch_extra_something
prefer_fetch_backend = self.watch.get('fetch_backend', 'system')
# Resolved via Watch → Tag (overrides_watch=True) → Global cascade.
from changedetectionio.model.resolver import resolve_setting
prefer_fetch_backend = resolve_setting(
self.watch, self.datastore, 'fetch_backend',
sentinel_values={'system'},
default='html_requests',
)
# Proxy ID "key"
preferred_proxy_id = preferred_proxy_id if preferred_proxy_id else self.datastore.get_preferred_proxy_for_watch(
uuid=self.watch.get('uuid'))
# Pluggable content self.fetcher
if not prefer_fetch_backend or prefer_fetch_backend == 'system':
prefer_fetch_backend = self.datastore.data['settings']['application'].get('fetch_backend')
# In the case that the preferred fetcher was a browser config with custom connection URL..
# @todo - on save watch, if its extra_browser_ then it should be obvious it will use playwright (like if its requests now..)
custom_browser_connection_url = None

View File

@@ -1,6 +1,7 @@
from babel.numbers import parse_decimal
from changedetectionio.model.Watch import model as BaseWatch
from decimal import Decimal, InvalidOperation
from typing import Union
import re
@@ -10,6 +11,8 @@ supports_browser_steps = True
supports_text_filters_and_triggers = True
supports_text_filters_and_triggers_elements = True
supports_request_type = True
_price_re = re.compile(r"Price:\s*(\d+(?:\.\d+)?)", re.IGNORECASE)
class Restock(dict):
@@ -63,6 +66,17 @@ class Restock(dict):
super().__setitem__(key, value)
def get_price_from_history_str(history_str):
m = _price_re.search(history_str)
if not m:
return None
try:
return str(Decimal(m.group(1)))
except InvalidOperation:
return None
class Watch(BaseWatch):
def __init__(self, *arg, **kw):
super().__init__(*arg, **kw)
@@ -76,13 +90,27 @@ class Watch(BaseWatch):
def extra_notification_token_values(self):
values = super().extra_notification_token_values()
values['restock'] = self.get('restock', {})
values['restock']['previous_price'] = None
if self.history_n >= 2:
history = self.history
if history and len(history) >=2:
"""Unfortunately for now timestamp is stored as string key"""
sorted_keys = sorted(list(history), key=lambda x: int(x))
sorted_keys.reverse()
price_str = self.get_history_snapshot(timestamp=sorted_keys[-1])
if price_str:
values['restock']['previous_price'] = get_price_from_history_str(price_str)
return values
def extra_notification_token_placeholder_info(self):
values = super().extra_notification_token_placeholder_info()
values.append(('restock.price', "Price detected"))
values.append(('restock.in_stock', "In stock status"))
values.append(('restock.original_price', "Original price at first check"))
values.append(('restock.previous_price', "Previous price in history"))
return values

View File

@@ -199,8 +199,31 @@ def handle_watch_update(socketio, **kwargs):
logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
def _suppress_werkzeug_ws_abrupt_disconnect_noise():
"""Patch BaseWSGIServer.log to suppress the AssertionError traceback that fires when
a browser closes a WebSocket connection mid-handshake (e.g. closing a tab).
The exception is caught inside run_wsgi and routed to self.server.log() — it never
propagates out, so wrapping run_wsgi doesn't help. Patching the log method is the
only reliable intercept point. The error is cosmetic: Socket.IO already handles the
disconnect correctly via its own disconnect handler and timeout logic."""
try:
from werkzeug.serving import BaseWSGIServer
_original_log = BaseWSGIServer.log
def _filtered_log(self, type, message, *args):
if type == 'error' and 'write() before start_response' in message:
return
_original_log(self, type, message, *args)
BaseWSGIServer.log = _filtered_log
except Exception:
pass
def init_socketio(app, datastore):
"""Initialize SocketIO with the main Flask app"""
_suppress_werkzeug_ws_abrupt_disconnect_noise()
import platform
import sys

View File

@@ -13,40 +13,47 @@ $(document).ready(function () {
$('#send-test-notification').click(function (e) {
e.preventDefault();
data = {
notification_urls: $('textarea.notification-urls').val(),
notification_title: $('input.notification-title').val(),
notification_body: $('textarea.notification-body').val(),
notification_format: $('select.notification-format').val(),
tags: $('#tags').val(),
var $btn = $(this);
var $spinner = $('.notifications-wrapper .spinner');
var $log = $('#notification-test-log');
$spinner.fadeIn();
$log.show();
$log.find('span').text('Sending...');
// Build data: if legacy notification_urls textarea exists (settings/group forms), include them
var data = {
window_url: window.location.href,
};
var $urlField = $('textarea.notification-urls');
if ($urlField.length) {
data.notification_urls = $urlField.val();
data.notification_title = $('input.notification-title').val();
data.notification_body = $('textarea.notification-body').val();
data.notification_format = $('select.notification-format').val();
data.tags = $('#tags').val();
}
$('.notifications-wrapper .spinner').fadeIn();
$('#notification-test-log').show();
$.ajax({
type: "POST",
url: notification_base_url,
data: data,
statusCode: {
400: function (data) {
$("#notification-test-log>span").text(data.responseText);
$log.find('span').text(data.responseText);
},
}
}).done(function (data) {
$("#notification-test-log>span").text(data);
$log.find('span').text(data);
}).fail(function (jqXHR, textStatus, errorThrown) {
// Handle connection refused or other errors
if (textStatus === "error" && errorThrown === "") {
console.error("Connection refused or server unreachable");
$("#notification-test-log>span").text("Error: Connection refused or server is unreachable.");
$log.find('span').text("Error: Connection refused or server is unreachable.");
} else {
console.error("Error:", textStatus, errorThrown);
$("#notification-test-log>span").text("An error occurred: " + textStatus);
$log.find('span').text("An error occurred: " + textStatus);
}
}).always(function () {
$('.notifications-wrapper .spinner').hide();
})
$spinner.hide();
});
});
});

View File

@@ -116,6 +116,14 @@ $(document).ready(function () {
$('#realtime-conn-error').show();
});
// Tell the server we're leaving cleanly so it can release the connection
// immediately rather than waiting for a timeout.
// Note: this only fires for voluntary closes (tab/window close, navigation away).
// Hard kills, crashes and network drops will still timeout normally on the server.
window.addEventListener('beforeunload', function () {
socket.disconnect();
});
socket.on('queue_size', function (data) {
console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);
if(queueSizePagerInfoText) {

View File

@@ -0,0 +1,181 @@
.notification-profile-selector {
position: relative;
.np-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-height: 32px;
padding: 4px 0;
}
.np-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 3px;
font-size: 0.82em;
line-height: 1.4;
background: var(--color-background-button-tag);
color: var(--color-white);
cursor: default;
max-width: 240px;
.np-chip-icon svg {
width: 12px;
height: 12px;
flex-shrink: 0;
}
.np-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.np-chip-own .np-chip-remove {
cursor: pointer;
margin-left: 2px;
opacity: 0.65;
font-size: 1.1em;
line-height: 1;
flex-shrink: 0;
&:hover { opacity: 1; }
}
&.np-chip-inherited {
opacity: 0.5;
border: 1px dashed var(--color-grey-600);
background: transparent;
color: var(--color-grey-500);
.np-chip-lock svg {
width: 10px;
height: 10px;
flex-shrink: 0;
}
}
}
.np-add-wrapper {
position: relative;
display: inline-block;
}
.np-add-btn {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: 1px dashed var(--color-grey-500);
color: var(--color-grey-400);
padding: 2px 8px;
font-size: 0.82em;
cursor: pointer;
border-radius: 3px;
&:hover {
border-color: var(--color-link);
color: var(--color-link);
}
svg { width: 12px; height: 12px; }
}
.np-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 200;
min-width: 300px;
max-width: 420px;
background: var(--color-background);
border: 1px solid var(--color-border-input);
border-radius: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
overflow: hidden;
.np-search {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border: none;
border-bottom: 1px solid var(--color-border-input);
outline: none;
font-size: 0.9em;
background: var(--color-background-input);
color: var(--color-text-input);
}
.np-options {
max-height: 220px;
overflow-y: auto;
}
.np-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
&:hover { background: var(--color-grey-900); }
.np-option-icon svg { width: 14px; height: 14px; flex-shrink: 0; }
.np-option-text {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
}
.np-option-name { font-size: 0.88em; font-weight: 600; white-space: nowrap; }
.np-option-hint { font-size: 0.76em; color: var(--color-text-input-description); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
}
.np-create-new {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--color-border-input);
font-size: 0.88em;
color: var(--color-link);
text-decoration: none;
&:hover { background: var(--color-grey-900); }
svg { width: 13px; height: 13px; }
}
}
}
// Profile type cards on the edit form
.profile-type-cards {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 6px 0;
.profile-type-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 16px;
border: 2px solid var(--color-border-input);
border-radius: 6px;
cursor: pointer;
font-size: 0.85em;
color: var(--color-grey-400);
min-width: 80px;
transition: border-color 0.15s, color 0.15s;
svg { width: 18px; height: 18px; }
input[type="radio"] { display: none; }
&.active, &:hover {
border-color: var(--color-link);
color: var(--color-link);
}
}
}

View File

@@ -32,6 +32,7 @@
@use "parts/toast";
@use "parts/login_form";
@use "parts/tabs";
@use "parts/notification_profiles";
// Smooth transitions for theme switching
body,
@@ -218,6 +219,40 @@ code {
font-weight: 900;
}
.watch-notif-profile {
@extend .inline-tag;
color: var(--color-white);
background: var(--color-link, #5c6bc0);
opacity: 0.8;
font-size: 0.7em;
cursor: default;
&.inherited {
opacity: 0.5;
}
&.system {
background: var(--color-grey-600, #888);
opacity: 0.55;
}
}
// Per-profile last-result badge in the profiles list
a.notif-last-result {
font-size: 0.82em;
font-weight: bold;
text-decoration: none;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
&.ok { color: #2a7c2a; background: rgba(42,124,42,0.10); }
&.test { color: #1a6fa8; background: rgba(26,111,168,0.10); }
&.error { color: #c0392b; background: rgba(192,57,43,0.10); }
&:hover { opacity: 0.75; }
}
.watch-tag-list {
color: var(--color-white);
background: var(--color-text-watch-tag-list);

File diff suppressed because one or more lines are too long

View File

@@ -775,3 +775,117 @@ class DatastoreUpdatesMixin:
tag.commit()
logger.info(f"update_30: migrated tag {tag_uuid} restock_settings → processor_config_restock_diff")
def update_31(self):
"""Migrate embedded notification settings to NotificationProfile objects.
Creates NotificationProfile entries in settings.application.notification_profile_data
from any existing notification_urls/title/body/format fields on watches, tags, and
system settings. Deduplicates identical configs to avoid redundant profiles.
Cleans up the old flat fields afterwards.
Safe to re-run: skips if notification_profile_data already exists.
"""
import uuid as uuid_mod
app = self.data['settings']['application']
# Idempotency: if we already ran, skip
if app.get('notification_profile_data'):
logger.info("update_31: notification_profile_data already exists, skipping")
return
app.setdefault('notification_profile_data', {})
app.setdefault('notification_profiles', [])
def _find_or_create(name, urls, title, body, fmt):
"""Return UUID of a matching existing profile or create a new one."""
for existing_uuid, p in app['notification_profile_data'].items():
c = p.get('config', {})
if (c.get('notification_urls') == urls
and c.get('notification_title') == title
and c.get('notification_body') == body
and c.get('notification_format') == fmt):
return existing_uuid
new_uuid = str(uuid_mod.uuid4())
app['notification_profile_data'][new_uuid] = {
'uuid': new_uuid,
'name': name,
'type': 'apprise',
'config': {
'notification_urls': urls,
'notification_title': title,
'notification_body': body,
'notification_format': fmt,
},
}
logger.info(f"update_31: created profile '{name}' ({new_uuid})")
return new_uuid
# 1. System-wide settings
sys_urls = app.get('notification_urls', [])
if sys_urls:
uid = _find_or_create(
name="System Default",
urls=sys_urls,
title=app.get('notification_title'),
body=app.get('notification_body'),
fmt=app.get('notification_format'),
)
if uid not in app['notification_profiles']:
app['notification_profiles'].append(uid)
# 2. Tags
for tag_uuid, tag in app.get('tags', {}).items():
tag_urls = tag.get('notification_urls', [])
if not tag_urls:
continue
uid = _find_or_create(
name=f"{tag.get('title', 'Group')} notifications",
urls=tag_urls,
title=tag.get('notification_title'),
body=tag.get('notification_body'),
fmt=tag.get('notification_format'),
)
tag.setdefault('notification_profiles', [])
if uid not in tag['notification_profiles']:
tag['notification_profiles'].append(uid)
tag.commit()
# 3. Watches
for watch_uuid, watch in self.data['watching'].items():
watch_urls = watch.get('notification_urls', [])
if not watch_urls:
continue
label = watch.get('title') or watch.get('url', watch_uuid)
uid = _find_or_create(
name=f"{label[:60]} notifications",
urls=watch_urls,
title=watch.get('notification_title'),
body=watch.get('notification_body'),
fmt=watch.get('notification_format'),
)
watch.setdefault('notification_profiles', [])
if uid not in watch['notification_profiles']:
watch['notification_profiles'].append(uid)
watch.commit()
# 4. Remove old flat fields from system settings
for key in ('notification_urls', 'notification_title', 'notification_body', 'notification_format'):
app.pop(key, None)
# 5. Strip old flat fields from tags
for tag in app.get('tags', {}).values():
for key in ('notification_urls', 'notification_title', 'notification_body', 'notification_format'):
tag.pop(key, None)
tag.commit()
# 6. Strip old flat fields from watches
for watch in self.data['watching'].values():
for key in ('notification_urls', 'notification_title', 'notification_body', 'notification_format'):
watch.pop(key, None)
watch.commit()
created = len(app['notification_profile_data'])
logger.success(f"update_31: migrated {created} notification profile(s)")
self.commit()

View File

@@ -7,6 +7,9 @@
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('tags.') %}active{% endif %}">
<a href="{{ url_for('tags.tags_overview_page') }}" class="pure-menu-link">{{ _('GROUPS') }}</a>
</li>
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('notification_profiles.') %}active{% endif %}">
<a href="{{ url_for('notification_profiles.index') }}" class="pure-menu-link">{{ _('NOTIFICATIONS') }}</a>
</li>
<li class="pure-menu-item menu-collapsible {% if request.endpoint.startswith('settings.') %}active{% endif %}">
<a href="{{ url_for('settings.settings_page') }}" class="pure-menu-link">{{ _('SETTINGS') }}</a>
</li>

View File

@@ -1,7 +1,9 @@
import os
import time
from flask import url_for
from .util import set_original_response, wait_for_all_checks, wait_for_notification_endpoint_output, delete_all_watches
from .util import (set_original_response, wait_for_all_checks, wait_for_notification_endpoint_output,
delete_all_watches, add_notification_profile, set_watch_notification_profile,
clear_notification_profiles)
from ..notification import valid_notification_formats
@@ -25,67 +27,71 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
# Response WITHOUT the filter ID element
set_original_response(datastore_path=datastore_path)
live_server.app.config['DATASTORE'].data['settings']['application']['notification_format'] = app_notification_format
# Goto the edit page, add our ignore text
notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'post')
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
# cleanup for the next
client.get(
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
)
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
notification_file = os.path.join(datastore_path, "notification.txt")
if os.path.isfile(notification_file):
os.unlink(notification_file)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
datastore = client.application.config.get('DATASTORE')
clear_notification_profiles(datastore)
uuid = datastore.add_watch(url=test_url)
res = client.get(url_for("watchlist.index"))
assert b'No web page change detection watches configured' not in res.data
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure"
watch_data = {"notification_urls": notification_url,
"notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
"notification_body": "BASE URL: {{base_url}}\n"
"Watch URL: {{watch_url}}\n"
"Watch UUID: {{watch_uuid}}\n"
"Watch title: {{watch_title}}\n"
"Watch tag: {{watch_tag}}\n"
"Preview: {{preview_url}}\n"
"Diff URL: {{diff_url}}\n"
"Snapshot: {{current_snapshot}}\n"
"Diff: {{diff}}\n"
"Diff Full: {{diff_full}}\n"
"Diff as Patch: {{diff_patch}}\n"
":-)",
"notification_format": 'text',
"fetch_backend": "html_requests",
"filter_failure_notification_send": 'y',
"time_between_check_use_default": "y",
"headers": "",
"tags": "my tag",
"title": "my title 123",
"time_between_check-hours": 5, # So that the queue runner doesnt also put it in
"url": test_url,
}
# Create a notification profile for this watch and link it
profile_uuid = add_notification_profile(
datastore,
notification_url=notification_url,
notification_title="New ChangeDetection.io Notification - {{watch_url}}",
notification_body=(
"BASE URL: {{base_url}}\n"
"Watch URL: {{watch_url}}\n"
"Watch UUID: {{watch_uuid}}\n"
"Watch title: {{watch_title}}\n"
"Watch tag: {{watch_tag}}\n"
"Preview: {{preview_url}}\n"
"Diff URL: {{diff_url}}\n"
"Snapshot: {{current_snapshot}}\n"
"Diff: {{diff}}\n"
"Diff Full: {{diff_full}}\n"
"Diff as Patch: {{diff_patch}}\n"
":-)"
),
notification_format=app_notification_format,
name="Filter Failure Test",
)
set_watch_notification_profile(datastore, uuid, profile_uuid)
# Update watch: set tags, title, filter_failure_notification_send
watch_data = {
"fetch_backend": "html_requests",
"filter_failure_notification_send": 'y',
"time_between_check_use_default": "y",
"headers": "",
"tags": "my tag",
"title": "my title 123",
"time_between_check-hours": 5,
"url": test_url,
"notification_profiles": profile_uuid,
}
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data=watch_data,
follow_redirects=True
)
assert b"Updated watch." in res.data
wait_for_all_checks(client)
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 0, "No filter = No filter failure"
@@ -99,16 +105,13 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
)
assert b"Updated watch." in res.data
# It should have checked once so far and given this error (because we hit SAVE)
wait_for_all_checks(client)
assert not os.path.isfile(notification_file)
# Hitting [save] would have triggered a recheck, and we have a filter, so this would be ONE failure
assert live_server.app.config['DATASTORE'].data['watching'][uuid]['consecutive_filter_failures'] == 1, "Should have been checked once"
# recheck it up to just before the threshold, including the fact that in the previous POST it would have rechecked (and incremented)
# Add 4 more checks
# recheck it up to just before the threshold
checked = 0
ATTEMPT_THRESHOLD_SETTING = live_server.app.config['DATASTORE'].data['settings']['application'].get('filter_failure_notification_threshold_attempts', 0)
for i in range(0, ATTEMPT_THRESHOLD_SETTING - 2):
@@ -137,24 +140,18 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
assert 'Your configured CSS/xPath filters' in notification
# Text (or HTML conversion) markup to make the notifications a little nicer should have worked
if app_notification_format.startswith('html'):
# apprise should have used sax-escape (&#39; instead of &quot;, " etc), lets check it worked
from apprise.conversion import convert_between
from apprise.common import NotifyFormat
escaped_filter = convert_between(NotifyFormat.TEXT, NotifyFormat.HTML, content_filter)
assert escaped_filter in notification or escaped_filter.replace('&quot;', '&#34;') in notification
assert 'a href="' in notification # Quotes should still be there so the link works
assert 'a href="' in notification
else:
assert 'a href' not in notification
assert content_filter in notification
# Remove it and prove that it doesn't trigger when not expected
# It should register a change, but no 'filter not found'
os.unlink(notification_file)
set_response_with_filter(datastore_path)
@@ -164,9 +161,7 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
# It should have sent a notification, but..
assert os.path.isfile(notification_file)
# but it should not contain the info about a failed filter (because there was none in this case)
with open(notification_file, 'r') as f:
notification = f.read()
assert not 'CSS/xPath filter was not present in the page' in notification
@@ -175,23 +170,19 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
assert uuid in notification
# cleanup for the next
client.get(
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
)
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
os.unlink(notification_file)
delete_all_watches(client)
clear_notification_profiles(datastore)
def test_check_include_filters_failure_notification(client, live_server, measure_memory_usage, datastore_path):
# # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('htmlcolor'), datastore_path=datastore_path)
# Check markup send conversion didnt affect plaintext preference
run_filter_test(client=client, live_server=live_server, content_filter='#nope-doesnt-exist', app_notification_format=valid_notification_formats.get('text'), datastore_path=datastore_path)
delete_all_watches(client)
def test_check_xpath_filter_failure_notification(client, live_server, measure_memory_usage, datastore_path):
# # live_server_setup(live_server) # Setup on conftest per function
run_filter_test(client=client, live_server=live_server, content_filter='//*[@id="nope-doesnt-exist"]', app_notification_format=valid_notification_formats.get('htmlcolor'), datastore_path=datastore_path)
delete_all_watches(client)

View File

@@ -5,8 +5,11 @@ import re
from flask import url_for
from loguru import logger
from .util import set_original_response, set_modified_response, set_more_modified_response, live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output
from . util import extract_UUID_from_client
from .util import (set_original_response, set_modified_response, set_more_modified_response,
live_server_setup, wait_for_all_checks, wait_for_notification_endpoint_output,
add_notification_profile, set_watch_notification_profile, set_system_notification_profile,
clear_notification_profiles)
from .util import extract_UUID_from_client
import logging
import base64
@@ -16,45 +19,32 @@ from changedetectionio.notification import (
default_notification_title, valid_notification_formats
)
from ..diff import HTML_CHANGED_STYLE
from ..model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH
from ..notification_service import FormattableTimestamp
# Hard to just add more live server URLs when one test is already running (I think)
# So we add our test here (was in a different file)
def test_check_notification(client, live_server, measure_memory_usage, datastore_path):
set_original_response(datastore_path=datastore_path)
# Re 360 - new install should have defaults set
notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') + "?status_code=204"
datastore = client.application.config.get('DATASTORE')
# Settings page should load OK with no inline notification fields (they're now in profiles)
res = client.get(url_for("settings.settings_page"))
notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')+"?status_code=204"
assert res.status_code == 200
assert default_notification_body.encode() in res.data
assert default_notification_title.encode() in res.data
#####################
# Set this up for when we remove the notification from the watch, it should fallback with these details
res = client.post(
url_for("settings.settings_page"),
data={"application-notification_urls": notification_url,
"application-notification_title": "fallback-title "+default_notification_title,
"application-notification_body": "fallback-body "+default_notification_body,
"application-notification_format": default_notification_format,
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
follow_redirects=True
# Create a system-level fallback profile
sys_profile_uuid = add_notification_profile(
datastore,
notification_url=notification_url,
notification_title="fallback-title " + default_notification_title,
notification_body="fallback-body " + default_notification_body,
notification_format=default_notification_format,
name="System Fallback",
)
assert b"Settings updated." in res.data
res = client.get(url_for("settings.settings_page"))
for k,v in valid_notification_formats.items():
if k == USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH:
continue
assert f'value="{k}"'.encode() in res.data # Should be by key NOT value
assert f'value="{v}"'.encode() not in res.data # Should be by key NOT value
set_system_notification_profile(datastore, sys_profile_uuid)
# When test mode is in BASE_URL env mode, we should see this already configured
env_base_url = os.getenv('BASE_URL', '').strip()
@@ -65,7 +55,6 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
else:
logging.debug(">>> SKIPPING BASE_URL check")
# re #242 - when you edited an existing new entry, it would not correctly show the notification settings
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
res = client.post(
@@ -79,66 +68,66 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
wait_for_all_checks(client)
# We write the PNG to disk, but a JPEG should appear in the notification
# Write the last screenshot png
testimage_png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
screenshot_dir = os.path.join(datastore_path, str(uuid))
os.makedirs(screenshot_dir, exist_ok=True)
with open(os.path.join(screenshot_dir, 'last-screenshot.png'), 'wb') as f:
f.write(base64.b64decode(testimage_png))
# Goto the edit page, add our ignore text
# Add our URL to the import page
print(">>>> Notification URL: " + notification_url)
print (">>>> Notification URL: "+notification_url)
notification_form_data = {"notification_urls": notification_url,
"notification_title": "New ChangeDetection.io Notification - {{watch_url}}",
"notification_body": "BASE URL: {{base_url}}\n"
"Watch URL: {{watch_url}}\n"
"Watch UUID: {{watch_uuid}}\n"
"Watch title: {{watch_title}}\n"
"Watch tag: {{watch_tag}}\n"
"Preview: {{preview_url}}\n"
"Diff URL: {{diff_url}}\n"
"Snapshot: {{current_snapshot}}\n"
"Diff: {{diff}}\n"
"Diff Added: {{diff_added}}\n"
"Diff Removed: {{diff_removed}}\n"
"Diff Full: {{diff_full}}\n"
"Diff with args: {{diff(context=3)}}"
"Diff as Patch: {{diff_patch}}\n"
"Change datetime: {{change_datetime}}\n"
"Change datetime format: Weekday {{change_datetime(format='%A')}}\n"
"Change datetime format: {{change_datetime(format='%Y-%m-%dT%H:%M:%S%z')}}\n"
":-)",
"notification_screenshot": True,
"notification_format": 'text'}
notification_form_data.update({
"url": test_url,
"tags": "my tag, my second tag",
"title": "my title",
"headers": "",
"fetch_backend": "html_requests",
"time_between_check_use_default": "y"})
# Create a watch-level notification profile with the full body template
watch_notification_body = (
"BASE URL: {{base_url}}\n"
"Watch URL: {{watch_url}}\n"
"Watch UUID: {{watch_uuid}}\n"
"Watch title: {{watch_title}}\n"
"Watch tag: {{watch_tag}}\n"
"Preview: {{preview_url}}\n"
"Diff URL: {{diff_url}}\n"
"Snapshot: {{current_snapshot}}\n"
"Diff: {{diff}}\n"
"Diff Added: {{diff_added}}\n"
"Diff Removed: {{diff_removed}}\n"
"Diff Full: {{diff_full}}\n"
"Diff with args: {{diff(context=3)}}"
"Diff as Patch: {{diff_patch}}\n"
"Change datetime: {{change_datetime}}\n"
"Change datetime format: Weekday {{change_datetime(format='%A')}}\n"
"Change datetime format: {{change_datetime(format='%Y-%m-%dT%H:%M:%S%z')}}\n"
":-)"
)
watch_profile_uuid = add_notification_profile(
datastore,
notification_url=notification_url,
notification_title="New ChangeDetection.io Notification - {{watch_url}}",
notification_body=watch_notification_body,
notification_format='text',
name="Watch Profile",
)
# Update the watch: set tags, title, screenshot, and link the profile
res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"),
data=notification_form_data,
data={
"url": test_url,
"tags": "my tag, my second tag",
"title": "my title",
"headers": "",
"fetch_backend": "html_requests",
"notification_screenshot": True,
"time_between_check_use_default": "y",
"notification_profiles": watch_profile_uuid,
},
follow_redirects=True
)
assert b"Updated watch." in res.data
# Hit the edit page, be sure that we saved it
# Re #242 - wasnt saving?
res = client.get(
url_for("ui.ui_edit.edit_page", uuid="first"))
assert bytes(notification_url.encode('utf-8')) in res.data
assert bytes("New ChangeDetection.io Notification".encode('utf-8')) in res.data
# Hit the edit page — profile name should appear
res = client.get(url_for("ui.ui_edit.edit_page", uuid="first"))
assert b"Watch Profile" in res.data
## Now recheck, and it should have sent the notification
wait_for_all_checks(client)
@@ -153,15 +142,11 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
res = client.get(url_for("watchlist.index"))
assert b'notification-error' not in res.data
# Verify what was sent as a notification, this file should exist
# Verify what was sent as a notification
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
notification_submission = f.read()
os.unlink(os.path.join(datastore_path, "notification.txt"))
# Did we see the URL that had a change, in the notification?
# Diff was correctly executed
assert "Diff Full: Some initial text" in notification_submission
assert "Diff: (changed) Which is across multiple lines" in notification_submission
assert "(into) which has this one new line" in notification_submission
@@ -177,57 +162,44 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
assert test_url in notification_submission
assert ':-)' in notification_submission
# Check the attachment was added, and that it is a JPEG from the original PNG
# Check the attachment was added
notification_submission_object = json.loads(notification_submission)
assert notification_submission_object
import time
# Could be from a few seconds ago (when the notification was fired vs in this test checking), so check for any
times_possible = [str(FormattableTimestamp(int(time.time()) - i)) for i in range(15)]
assert any(t in notification_submission for t in times_possible)
txt = f"Weekday {FormattableTimestamp(int(time.time()))(format='%A')}"
assert txt in notification_submission
# We keep PNG screenshots for now
# IF THIS FAILS YOU SHOULD BE TESTING WITH ENV VAR REMOVE_REQUESTS_OLD_SCREENSHOTS=False
assert notification_submission_object['attachments'][0]['filename'] == 'last-screenshot.png'
assert len(notification_submission_object['attachments'][0]['base64'])
assert notification_submission_object['attachments'][0]['mimetype'] == 'image/png'
jpeg_in_attachment = base64.b64decode(notification_submission_object['attachments'][0]['base64'])
# Assert that the JPEG is readable (didn't get chewed up somewhere)
from PIL import Image
import io
assert Image.open(io.BytesIO(jpeg_in_attachment))
if env_base_url:
# Re #65 - did we see our BASE_URl ?
logging.debug (">>> BASE_URL checking in notification: %s", env_base_url)
logging.debug(">>> BASE_URL checking in notification: %s", env_base_url)
assert env_base_url in notification_submission
else:
logging.debug(">>> Skipping BASE_URL check")
# This should insert the {current_snapshot}
set_more_modified_response(datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
# Verify what was sent as a notification, this file should exist
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
notification_submission = f.read()
assert "Ohh yeah awesome" in notification_submission
# Prove that "content constantly being marked as Changed with no Updating causes notification" is not a thing
# https://github.com/dgtlmoon/changedetection.io/discussions/192
os.unlink(os.path.join(datastore_path, "notification.txt"))
# Trigger a check
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
@@ -237,30 +209,18 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
assert os.path.exists(os.path.join(datastore_path, "notification.txt")) == False
res = client.get(url_for("settings.notification_logs"))
# be sure we see it in the output log
assert b'New ChangeDetection.io Notification - ' + test_url.encode('utf-8') in res.data
# Now unlink the watch profile — it should fall back to the system profile
set_original_response(datastore_path=datastore_path)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"),
data={
"url": test_url,
"tags": "my tag",
"title": "my title",
"notification_urls": '',
"notification_title": '',
"notification_body": '',
"notification_format": default_notification_format,
"fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True
)
assert b"Updated watch." in res.data
watch = datastore.data['watching'][uuid]
watch['notification_profiles'] = []
watch.commit()
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
# Verify what was sent as a notification, this file should exist
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
notification_submission = f.read()
assert "fallback-title" in notification_submission
@@ -271,60 +231,51 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
)
clear_notification_profiles(datastore)
def test_notification_urls_jinja2_apprise_integration(client, live_server, measure_memory_usage, datastore_path):
#
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation
test_notification_url = "hassio://127.0.0.1/longaccesstoken?verify=no&nid={{watch_uuid}}"
datastore = client.application.config.get('DATASTORE')
res = client.post(
url_for("settings.settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了", "another": "{{diff|truncate(1500)}}" }',
"application-notification_format": default_notification_format,
"application-notification_urls": test_notification_url,
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }} {{diff|truncate(200)}} ",
},
follow_redirects=True
profile_uuid = add_notification_profile(
datastore,
notification_url=test_notification_url,
notification_body='{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了", "another": "{{diff|truncate(1500)}}" }',
notification_format=default_notification_format,
notification_title="New ChangeDetection.io Notification - {{ watch_url }} {{diff|truncate(200)}} ",
name="Jinja2 Integration Test",
)
assert b'Settings updated' in res.data
assert '网站监测'.encode() in res.data
assert b'{{diff|truncate(1500)}}' in res.data
assert b'{{diff|truncate(200)}}' in res.data
set_system_notification_profile(datastore, profile_uuid)
# Verify settings page loads OK
res = client.get(url_for("settings.settings_page"))
assert res.status_code == 200
clear_notification_profiles(datastore)
def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_memory_usage, datastore_path):
# test_endpoint - that sends the contents of a file
# test_notification_endpoint - that takes a POST and writes it to file (test-datastore/notification.txt)
# CUSTOM JSON BODY CHECK for POST://
set_original_response(datastore_path=datastore_path)
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#header-manipulation
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?status_code=204&watch_uuid={{ watch_uuid }}&xxx={{ watch_url }}&now={% now 'Europe/London', '%Y-%m-%d' %}&+custom-header=123&+second=hello+world%20%22space%22"
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?status_code=204&watch_uuid={{ watch_uuid }}&xxx={{ watch_url }}&now={% now 'Europe/London', '%Y-%m-%d' %}&+custom-header=123&+second=hello+world%20%22space%22"
res = client.post(
url_for("settings.settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"application-notification_body": '{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了" }',
"application-notification_format": default_notification_format,
"application-notification_urls": test_notification_url,
# https://github.com/caronc/apprise/wiki/Notify_Custom_JSON#get-parameter-manipulation
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }} ",
},
follow_redirects=True
datastore = client.application.config.get('DATASTORE')
profile_uuid = add_notification_profile(
datastore,
notification_url=test_notification_url,
notification_body='{ "url" : "{{ watch_url }}", "secret": 444, "somebug": "网站监测 内容更新了" }',
notification_format=default_notification_format,
notification_title="New ChangeDetection.io Notification - {{ watch_url }} ",
name="Custom Endpoint Test",
)
assert b'Settings updated' in res.data
set_system_notification_profile(datastore, profile_uuid)
# Add a watch and trigger a HTTP POST
test_url = url_for('test_endpoint', _external=True)
@@ -339,7 +290,6 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
wait_for_notification_endpoint_output(datastore_path=datastore_path)
# Check no errors were recorded, because we asked for 204 which is slightly uncommon but is still OK
res = client.get(url_for("watchlist.index"))
assert b'notification-error' not in res.data
@@ -351,7 +301,6 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
assert j['secret'] == 444
assert j['somebug'] == '网站监测 内容更新了'
# URL check, this will always be converted to lowercase
assert os.path.isfile(os.path.join(datastore_path, "notification-url.txt"))
with open(os.path.join(datastore_path, "notification-url.txt"), 'r') as f:
@@ -364,13 +313,11 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
# Check our watch_uuid appeared
assert f'watch_uuid={watch_uuid}' in notification_url
with open(os.path.join(datastore_path, "notification-headers.txt"), 'r') as f:
notification_headers = f.read()
assert 'custom-header: 123' in notification_headers.lower()
assert 'second: hello world "space"' in notification_headers.lower()
# Should always be automatically detected as JSON content type even when we set it as 'Plain Text' (default)
assert os.path.isfile(os.path.join(datastore_path, "notification-content-type.txt"))
with open(os.path.join(datastore_path, "notification-content-type.txt"), 'r') as f:
@@ -382,6 +329,7 @@ def test_notification_custom_endpoint_and_jinja2(client, live_server, measure_me
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
)
clear_notification_profiles(datastore)
#2510
@@ -390,25 +338,22 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
set_original_response(datastore_path=datastore_path)
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
os.unlink(os.path.join(datastore_path, "notification.txt")) \
os.unlink(os.path.join(datastore_path, "notification.txt"))
# 1995 UTF-8 content should be encoded
test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}'
datastore = client.application.config.get('DATASTORE')
# otherwise other settings would have already existed from previous tests in this file
res = client.post(
url_for("settings.settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"application-notification_body": test_body,
"application-notification_format": default_notification_format,
"application-notification_urls": "",
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
},
follow_redirects=True
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}&+custom-header=123"
profile_uuid = add_notification_profile(
datastore,
notification_url=test_notification_url,
notification_body=test_body,
notification_format=default_notification_format,
notification_title="New ChangeDetection.io Notification - {{ watch_url }}",
name="Global Test Profile",
)
assert b'Settings updated' in res.data
set_system_notification_profile(datastore, profile_uuid)
test_url = url_for('test_endpoint', _external=True)
res = client.post(
@@ -416,15 +361,14 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
data={"url": test_url, "tags": 'nice one'},
follow_redirects=True
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123"
######### Test global/system settings
######### Test using the resolved profiles endpoint
uuid = next(iter(datastore.data['watching']))
res = client.post(
url_for("ui.ui_notification.ajax_callback_send_notification_test")+"?mode=global-settings",
data={"notification_urls": test_notification_url},
url_for("ui.ui_notification.ajax_callback_send_notification_test", watch_uuid=uuid),
data={},
follow_redirects=True
)
@@ -435,7 +379,6 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
x = f.read()
assert 'change detection is cool 网站监测 内容更新了' in x
if 'html' in default_notification_format:
# this should come from default text when in global/system mode here changedetectionio/notification_service.py
assert 'title="Changed into">Example text:' in x
else:
assert 'title="Changed into">Example text:' not in x
@@ -444,31 +387,21 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
os.unlink(os.path.join(datastore_path, "notification.txt"))
######### Test group/tag settings
res = client.post(
url_for("ui.ui_notification.ajax_callback_send_notification_test")+"?mode=group-settings",
data={"notification_urls": test_notification_url},
follow_redirects=True
## Check that 'test' catches errors with a bad profile
bad_profile_uuid = add_notification_profile(
datastore,
notification_url='post://akjsdfkjasdkfjasdkfjasdkjfas232323/should-error',
name="Bad Profile",
)
set_watch_notification_profile(datastore, uuid, bad_profile_uuid)
# Remove system profile from watch so only bad profile fires
watch = datastore.data['watching'][uuid]
watch['notification_profiles'] = [bad_profile_uuid]
watch.commit()
assert res.status_code != 400
assert res.status_code != 500
# Give apprise time to fire
wait_for_notification_endpoint_output(datastore_path=datastore_path)
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
x = f.read()
# Should come from notification.py default handler when there is no notification body to pull from
assert 'change detection is cool 网站监测 内容更新了' in x
## Check that 'test' catches errors
test_notification_url = 'post://akjsdfkjasdkfjasdkfjasdkjfas232323/should-error'
######### Test global/system settings
res = client.post(
url_for("ui.ui_notification.ajax_callback_send_notification_test")+"?mode=global-settings",
data={"notification_urls": test_notification_url},
url_for("ui.ui_notification.ajax_callback_send_notification_test", watch_uuid=uuid),
data={},
follow_redirects=True
)
assert res.status_code == 400
@@ -480,47 +413,54 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
b"Failed to establish a new connection" in res.data or
b"Connection error occurred" in res.data
)
client.get(
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
)
######### Test global/system settings - When everything is deleted it should give a helpful error
# See #2727
# When everything is deleted with no watches, expect helpful error
res = client.post(
url_for("ui.ui_notification.ajax_callback_send_notification_test")+"?mode=global-settings",
data={"notification_urls": test_notification_url},
url_for("ui.ui_notification.ajax_callback_send_notification_test"),
data={},
follow_redirects=True
)
assert res.status_code == 400
assert b"Error: You must have atleast one watch configured for 'test notification' to work" in res.data
clear_notification_profiles(datastore)
#2510
def test_single_send_test_notification_on_watch(client, live_server, measure_memory_usage, datastore_path):
set_original_response(datastore_path=datastore_path)
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
os.unlink(os.path.join(datastore_path, "notification.txt")) \
os.unlink(os.path.join(datastore_path, "notification.txt"))
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123"
# 1995 UTF-8 content should be encoded
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}&+custom-header=123"
test_body = 'change detection is cool 网站监测 内容更新了 - {{diff_full}}\n\nCurrent snapshot: {{current_snapshot}}'
######### Test global/system settings
datastore = client.application.config.get('DATASTORE')
profile_uuid = add_notification_profile(
datastore,
notification_url=test_notification_url,
notification_body=test_body,
notification_format=default_notification_format,
notification_title="New ChangeDetection.io Notification - {{ watch_url }}",
name="Single Watch Test",
)
set_watch_notification_profile(datastore, uuid, profile_uuid)
######### Test single-watch notification via resolved profiles
res = client.post(
url_for("ui.ui_notification.ajax_callback_send_notification_test")+f"/{uuid}",
data={"notification_urls": test_notification_url,
"notification_body": test_body,
"notification_format": default_notification_format,
"notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
},
url_for("ui.ui_notification.ajax_callback_send_notification_test", watch_uuid=uuid),
data={},
follow_redirects=True
)
@@ -531,15 +471,16 @@ def test_single_send_test_notification_on_watch(client, live_server, measure_mem
x = f.read()
assert 'change detection is cool 网站监测 内容更新了' in x
if 'html' in default_notification_format:
# this should come from default text when in global/system mode here changedetectionio/notification_service.py
assert 'title="Changed into">Example text:' in x
else:
assert 'title="Changed into">Example text:' not in x
assert 'span' not in x
assert 'Example text:' in x
#3720 current_snapshot check, was working but lets test it exactly.
#3720 current_snapshot check
assert 'Current snapshot: Example text: example test' in x
os.unlink(os.path.join(datastore_path, "notification.txt"))
clear_notification_profiles(datastore)
def _test_color_notifications(client, notification_body_token, datastore_path):
@@ -548,24 +489,18 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
os.unlink(os.path.join(datastore_path, "notification.txt"))
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}&+custom-header=123"
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')+"?xxx={{ watch_url }}&+custom-header=123"
# otherwise other settings would have already existed from previous tests in this file
res = client.post(
url_for("settings.settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"application-notification_body": notification_body_token,
"application-notification_format": "htmlcolor",
"application-notification_urls": test_notification_url,
"application-notification_title": "New ChangeDetection.io Notification - {{ watch_url }}",
},
follow_redirects=True
datastore = client.application.config.get('DATASTORE')
profile_uuid = add_notification_profile(
datastore,
notification_url=test_notification_url,
notification_body=notification_body_token,
notification_format="htmlcolor",
notification_title="New ChangeDetection.io Notification - {{ watch_url }}",
name="Color Notification Test",
)
assert b'Settings updated' in res.data
set_system_notification_profile(datastore, profile_uuid)
test_url = url_for('test_endpoint', _external=True)
res = client.post(
@@ -573,14 +508,11 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
data={"url": test_url, "tags": 'nice one'},
follow_redirects=True
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
set_modified_response(datastore_path=datastore_path)
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
assert b'Queued 1 watch for rechecking.' in res.data
@@ -596,9 +528,10 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
)
clear_notification_profiles(datastore)
# Just checks the format of the colour notifications was correct
def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path)
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path)
_test_color_notifications(client, '{{diff}}', datastore_path=datastore_path)
_test_color_notifications(client, '{{diff_full}}', datastore_path=datastore_path)

View File

@@ -1,15 +1,16 @@
import os
import time
from flask import url_for
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, delete_all_watches
from .util import (set_original_response, set_modified_response, live_server_setup, wait_for_all_checks,
delete_all_watches, add_notification_profile, set_watch_notification_profile,
clear_notification_profiles)
import logging
def test_check_notification_error_handling(client, live_server, measure_memory_usage, datastore_path):
# live_server_setup(live_server) # Setup on conftest per function
set_original_response(datastore_path=datastore_path)
# Set a URL and fetch it, then set a notification URL which is going to give errors
# Set a URL and fetch it, then set notification profiles — one broken, one working
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
@@ -24,49 +25,47 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u
working_notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json')
broken_notification_url = "jsons://broken-url-xxxxxxxx123/test"
res = client.post(
url_for("ui.ui_edit.edit_page", uuid="first"),
# A URL with errors should not block the one that is working
data={"notification_urls": f"{broken_notification_url}\r\n{working_notification_url}",
"notification_title": "xxx",
"notification_body": "xxxxx",
"notification_format": 'text',
"url": test_url,
"tags": "",
"title": "",
"headers": "",
"time_between_check-minutes": "180",
"fetch_backend": "html_requests",
"time_between_check_use_default": "y"},
follow_redirects=True
datastore = client.application.config.get('DATASTORE')
uuid = next(iter(datastore.data['watching']))
# A broken URL in a profile should not block a working profile from firing
broken_profile_uuid = add_notification_profile(
datastore,
notification_url=broken_notification_url,
notification_title="xxx",
notification_body="xxxxx",
notification_format='text',
name="Broken Profile",
)
assert b"Updated watch." in res.data
working_profile_uuid = add_notification_profile(
datastore,
notification_url=working_notification_url,
notification_title="xxx",
notification_body="xxxxx",
notification_format='text',
name="Working Profile",
)
set_watch_notification_profile(datastore, uuid, broken_profile_uuid)
set_watch_notification_profile(datastore, uuid, working_profile_uuid)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
found=False
found = False
for i in range(1, 10):
logging.debug("Fetching watch overview....")
res = client.get(
url_for("watchlist.index"))
res = client.get(url_for("watchlist.index"))
if bytes("Notification error detected".encode('utf-8')) in res.data:
found=True
found = True
break
time.sleep(1)
assert found
# The error should show in the notification logs
res = client.get(
url_for("settings.notification_logs"))
# Check for various DNS/connection error patterns that may appear in different environments
res = client.get(url_for("settings.notification_logs"))
found_name_resolution_error = (
b"No address found" in res.data or
b"No address found" in res.data or
b"Name or service not known" in res.data or
b"nodename nor servname provided" in res.data or
b"Temporary failure in name resolution" in res.data or
@@ -75,10 +74,11 @@ def test_check_notification_error_handling(client, live_server, measure_memory_u
)
assert found_name_resolution_error
# And the working one, which is after the 'broken' one should still have fired
# And the working one should still have fired
with open(os.path.join(datastore_path, "notification.txt"), "r") as f:
notification_submission = f.read()
os.unlink(os.path.join(datastore_path, "notification.txt"))
assert 'xxxxx' in notification_submission
delete_all_watches(client)
clear_notification_profiles(datastore)

View File

@@ -0,0 +1,111 @@
"""
Test registering a custom NotificationProfileType via registry.register().
Verifies that:
- A third-party type can be registered alongside the built-in Apprise type
- The registry resolves it correctly by type_id
- A watch linked to a profile of that type fires send() when a change is detected
- The custom send() receives a populated NotificationContextData object
"""
import uuid as uuid_mod
from flask import url_for
from changedetectionio.tests.util import (
set_original_response,
set_modified_response,
live_server_setup,
wait_for_all_checks,
wait_for_notification_endpoint_output,
)
def test_custom_notification_profile_type_registration(client, live_server, measure_memory_usage, datastore_path):
"""
Register a custom NotificationProfileType that POSTs to the test endpoint,
link it to a watch via a profile, trigger a change, and confirm the custom
send() was called.
"""
from changedetectionio.notification_profiles.registry import registry, NotificationProfileType
# ── 1. Define and register a custom type ─────────────────────────────────
class WebhookProfileType(NotificationProfileType):
"""Simple webhook type: POSTs watch_url + watch_title JSON to a webhook_url."""
type_id = 'test_webhook'
display_name = 'Test Webhook'
icon = 'send'
template = 'notification_profiles/types/apprise.html' # reuse apprise template for UI
def send(self, config: dict, n_object, datastore) -> bool:
import requests as req
webhook_url = config.get('webhook_url')
if not webhook_url:
return False
payload = {
'watch_url': n_object.get('watch_url', ''),
'watch_title': n_object.get('watch_title', ''),
'diff': n_object.get('diff', ''),
}
req.post(webhook_url, json=payload, timeout=5)
return True
def validate(self, config: dict) -> None:
if not config.get('webhook_url'):
raise ValueError("webhook_url is required")
# Register — idempotent if test runs more than once in a session
registry.register(WebhookProfileType)
assert registry.get('test_webhook') is not None, "Custom type should be in registry after register()"
assert registry.get('test_webhook').type_id == 'test_webhook'
# ── 2. Set up live server and test content ────────────────────────────────
live_server_setup(live_server)
set_original_response(datastore_path=datastore_path)
datastore = client.application.config.get('DATASTORE')
webhook_url = url_for('test_notification_endpoint', _external=True)
# ── 3. Create a profile using the custom type ─────────────────────────────
uid = str(uuid_mod.uuid4())
datastore.data['settings']['application'].setdefault('notification_profile_data', {})[uid] = {
'uuid': uid,
'name': 'Custom Webhook Profile',
'type': 'test_webhook',
'config': {'webhook_url': webhook_url},
}
# ── 4. Add a watch ────────────────────────────────────────────────────────
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "tags": ''},
follow_redirects=True,
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
watch_uuid = next(iter(datastore.data['watching']))
# Link the custom profile to the watch
datastore.data['watching'][watch_uuid]['notification_profiles'] = [uid]
datastore.data['watching'][watch_uuid].commit()
# ── 5. Trigger a change ───────────────────────────────────────────────────
set_modified_response(datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# ── 6. Verify the custom send() was called ────────────────────────────────
assert wait_for_notification_endpoint_output(datastore_path), \
"Custom WebhookProfileType.send() should have POSTed to the test notification endpoint"
# ── 7. Cleanup: unregister the test type so it doesn't bleed into other tests ──
registry._types.pop('test_webhook', None)

View File

@@ -350,6 +350,7 @@ def test_change_with_notification_values(client, live_server, measure_memory_usa
res = client.get(url_for("settings.settings_page"))
assert b'{{restock.original_price}}' in res.data
assert b'{{restock.previous_price}}' in res.data
assert b'Original price at first check' in res.data
#####################
@@ -358,7 +359,7 @@ def test_change_with_notification_values(client, live_server, measure_memory_usa
url_for("settings.settings_page"),
data={"application-notification_urls": notification_url,
"application-notification_title": "title new price {{restock.price}}",
"application-notification_body": "new price {{restock.price}}",
"application-notification_body": "new price {{restock.price}} previous price {{restock.previous_price}} instock {{restock.in_stock}}",
"application-notification_format": default_notification_format,
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
@@ -372,8 +373,6 @@ def test_change_with_notification_values(client, live_server, measure_memory_usa
assert b"Settings updated." in res.data
set_original_response(props_markup=instock_props[0], price='960.45', datastore_path=datastore_path)
# A change in price, should trigger a change by default
set_original_response(props_markup=instock_props[0], price='1950.45', datastore_path=datastore_path)
client.get(url_for("ui.form_watch_checknow"))
@@ -384,6 +383,7 @@ def test_change_with_notification_values(client, live_server, measure_memory_usa
notification = f.read()
assert "new price 1950.45" in notification
assert "title new price 1950.45" in notification
assert "previous price 960.45" in notification
## Now test the "SEND TEST NOTIFICATION" is working
os.unlink(os.path.join(datastore_path, "notification.txt"))

View File

@@ -115,6 +115,55 @@ def set_empty_text_response(datastore_path):
return None
def add_notification_profile(datastore, notification_url, notification_title='', notification_body='',
notification_format='text', name='Test Profile'):
"""Create a notification profile in the datastore and return its UUID."""
import uuid as uuid_mod
uid = str(uuid_mod.uuid4())
urls = [notification_url] if isinstance(notification_url, str) else notification_url
datastore.data['settings']['application'].setdefault('notification_profile_data', {})[uid] = {
'uuid': uid,
'name': name,
'type': 'apprise',
'config': {
'notification_urls': urls,
'notification_title': notification_title or None,
'notification_body': notification_body or None,
'notification_format': notification_format or None,
},
}
return uid
def set_watch_notification_profile(datastore, watch_uuid, profile_uuid):
"""Link a notification profile UUID to a specific watch."""
watch = datastore.data['watching'][watch_uuid]
profiles = list(watch.get('notification_profiles', []))
if profile_uuid not in profiles:
profiles.append(profile_uuid)
watch['notification_profiles'] = profiles
watch.commit()
def set_system_notification_profile(datastore, profile_uuid):
"""Link a notification profile UUID to system settings."""
app = datastore.data['settings']['application']
profiles = list(app.get('notification_profiles', []))
if profile_uuid not in profiles:
profiles.append(profile_uuid)
app['notification_profiles'] = profiles
def clear_notification_profiles(datastore):
"""Remove all notification profiles and links."""
app = datastore.data['settings']['application']
app['notification_profile_data'] = {}
app['notification_profiles'] = []
for watch in datastore.data['watching'].values():
watch['notification_profiles'] = []
watch.commit()
def wait_for_notification_endpoint_output(datastore_path):
'''Apprise can take a few seconds to fire'''
#@todo - could check the apprise object directly instead of looking for this file