mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-03-22 03:37:56 +00:00
Compare commits
5 Commits
ui/setting
...
hierarchy-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed50c160d | ||
|
|
b98f55030a | ||
|
|
6181b09b16 | ||
|
|
5f9fa15a6a | ||
|
|
34c2c05bc5 |
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
211
changedetectionio/blueprint/notification_profiles/__init__.py
Normal file
211
changedetectionio/blueprint/notification_profiles/__init__.py
Normal 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)
|
||||
36
changedetectionio/blueprint/notification_profiles/forms.py
Normal file
36
changedetectionio/blueprint/notification_profiles/forms.py
Normal 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'],
|
||||
)
|
||||
@@ -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 %}
|
||||
@@ -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' -%}✓ {{ _('OK') }}
|
||||
{%- elif _last.status == 'test' -%}▶ {{ _('Test') }}
|
||||
{%- else -%}✗ {{ _('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 %}
|
||||
@@ -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">← {{ _('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">✓ {{ _('OK') }}</span>
|
||||
{% elif e.status == 'test' %}
|
||||
<span class="notif-log-status test">▶ {{ _('Test') }}</span>
|
||||
{% else %}
|
||||
<span class="notif-log-status error">✗ {{ _('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 %}
|
||||
@@ -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>
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') }} ‐ {{ _('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>
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') }} ‐ {{ _('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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (' instead of ", " 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('"', '"') 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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
111
changedetectionio/tests/test_notification_profile_custom_type.py
Normal file
111
changedetectionio/tests/test_notification_profile_custom_type.py
Normal 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)
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user