mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-27 15:56:45 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e77c5aad7 | ||
|
|
d7e83e53b6 | ||
|
|
0f4b556af0 | ||
|
|
7a3bc2ab9e | ||
|
|
3a71777499 | ||
|
|
e0fb224d41 | ||
|
|
050172129e | ||
|
|
e573837789 | ||
|
|
3d6de42924 | ||
|
|
fbd9472218 | ||
|
|
2669a5cee6 | ||
|
|
68a445df1b | ||
|
|
af234636b9 | ||
|
|
7708ffa986 | ||
|
|
59c9e9e4be | ||
|
|
f9e3627d82 | ||
|
|
97317398ac | ||
|
|
10521dd5e1 | ||
|
|
227b011d73 | ||
|
|
780c8dc8af | ||
|
|
dce6f23cf9 | ||
|
|
2b0c5e24e6 | ||
|
|
143a0f116f | ||
|
|
9e54ed3adb | ||
|
|
7b61c20e68 |
@@ -52,6 +52,19 @@ jobs:
|
||||
git diff --stat changedetectionio/translations
|
||||
exit 1
|
||||
fi
|
||||
- name: Check translation overlay
|
||||
# Deliberately after extract_messages above, so overrides are validated against a freshly
|
||||
# extracted messages.pot. An overlay entry is keyed on the exact upstream msgid: when a
|
||||
# string is reworded upstream the override stops matching and silently reverts to upstream
|
||||
# wording. This is the only thing that makes that visible.
|
||||
# See changedetectionio/translations_overlay/README.md
|
||||
if: hashFiles('changedetectionio/translations_overlay/**/*.po') != ''
|
||||
run: |
|
||||
find changedetectionio/translations_overlay -name "*.po" | while read f; do
|
||||
echo "Checking $f"
|
||||
msgfmt --check-format -o /dev/null "$f"
|
||||
done
|
||||
python changedetectionio/translations_overlay/manage.py check
|
||||
|
||||
lint-template-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -103,15 +116,6 @@ jobs:
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
|
||||
test-application-3-10:
|
||||
# Only run on push to master (including PR merges)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
needs: [lint-code, lint-translations, lint-template-i18n]
|
||||
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
|
||||
test-application-3-11:
|
||||
# Always run
|
||||
needs: [lint-code, lint-translations, lint-template-i18n]
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Minimum supported version
|
||||
target-version = "py310"
|
||||
target-version = "py311"
|
||||
|
||||
# Formatting options
|
||||
line-length = 100
|
||||
|
||||
@@ -13,6 +13,7 @@ recursive-include changedetectionio/store *
|
||||
recursive-include changedetectionio/templates *
|
||||
recursive-include changedetectionio/tests *
|
||||
recursive-include changedetectionio/translations *
|
||||
recursive-include changedetectionio/translations_overlay *
|
||||
recursive-include changedetectionio/widgets *
|
||||
prune changedetectionio/static/package-lock.json
|
||||
prune changedetectionio/static/styles/node_modules
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
# Semver means never use .01, or 00. Should be .1.
|
||||
__version__ = '0.55.8'
|
||||
__version__ = '0.60.3'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -194,6 +194,15 @@ class Import(Resource):
|
||||
|
||||
urls_to_import.append(url)
|
||||
|
||||
# PAGE_WATCH_LIMIT - refuse the whole batch rather than importing an arbitrary prefix of
|
||||
# it, so a 429 always means "nothing was created" and the caller can retry as-is
|
||||
watch_limit = self.datastore.watch_limit
|
||||
if watch_limit is not None:
|
||||
current_watch_count = len(self.datastore.data['watching'])
|
||||
if current_watch_count + len(urls_to_import) > watch_limit:
|
||||
return (f"Watch limit reached ({current_watch_count}/{watch_limit} watches), importing "
|
||||
f"{len(urls_to_import)} URL(s) would exceed it. No watches were imported.", 429)
|
||||
|
||||
# For small imports, process synchronously for immediate feedback
|
||||
if len(urls_to_import) < IMPORT_SWITCH_TO_BACKGROUND_THRESHOLD:
|
||||
added = []
|
||||
|
||||
@@ -18,6 +18,42 @@ from ..notification import valid_notification_formats
|
||||
from ..notification.handler import newline_re
|
||||
|
||||
|
||||
def validate_time_schedule_limit(json_data):
|
||||
"""
|
||||
Validate the optional timezone inside time_schedule_limit.
|
||||
|
||||
The edit form runs validateTimeZoneName on this field, but the API did not -
|
||||
the OpenAPI schema for time_schedule_limit does not declare a `timezone`
|
||||
property at all and does not set additionalProperties:false, so any string
|
||||
passed straight through and was stored.
|
||||
|
||||
That matters because the scheduler resolves it with arrow.now(tz), which
|
||||
raises on an unknown zone. Before the ticker thread was hardened, a single
|
||||
authenticated PUT with a bogus timezone stopped scheduling for EVERY watch
|
||||
on the instance. It is now contained to the one watch, but that watch would
|
||||
still silently never be checked again, so reject it at the boundary.
|
||||
|
||||
Returns None if valid, or an error message string if invalid.
|
||||
"""
|
||||
schedule = json_data.get('time_schedule_limit')
|
||||
if not isinstance(schedule, dict):
|
||||
return None
|
||||
|
||||
tz_name = schedule.get('timezone')
|
||||
if not tz_name:
|
||||
return None
|
||||
|
||||
if not isinstance(tz_name, str):
|
||||
return "time_schedule_limit.timezone must be a string IANA timezone name, e.g. 'Europe/Berlin'."
|
||||
|
||||
from zoneinfo import available_timezones
|
||||
if tz_name.strip() not in available_timezones():
|
||||
return (f"time_schedule_limit.timezone '{tz_name}' is not a valid timezone name. "
|
||||
f"Use an IANA name such as 'Europe/Berlin' or 'UTC'.")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_time_between_check_required(json_data):
|
||||
"""
|
||||
Validate that at least one time interval is specified when not using default settings.
|
||||
@@ -159,6 +195,11 @@ class Watch(Resource):
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# An invalid timezone here makes the watch permanently unschedulable
|
||||
validation_error = validate_time_schedule_limit(request.json)
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# Validate notification_urls if provided
|
||||
if 'notification_urls' in request.json:
|
||||
from wtforms import ValidationError
|
||||
@@ -488,6 +529,11 @@ class CreateWatch(Resource):
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# An invalid timezone here makes the watch permanently unschedulable
|
||||
validation_error = validate_time_schedule_limit(json_data)
|
||||
if validation_error:
|
||||
return validation_error, 400
|
||||
|
||||
# Validate notification_urls if provided
|
||||
if 'notification_urls' in json_data:
|
||||
from wtforms import ValidationError
|
||||
@@ -514,6 +560,12 @@ class CreateWatch(Resource):
|
||||
|
||||
del extras['url']
|
||||
|
||||
# PAGE_WATCH_LIMIT - checked up front so a blocked add is reported as 429 rather than
|
||||
# being guessed at from add_watch() returning None
|
||||
if self.datastore.watch_limit_reached():
|
||||
current_watch_count = len(self.datastore.data['watching'])
|
||||
return f"Watch limit reached ({current_watch_count}/{self.datastore.watch_limit} watches). Cannot add more watches.", 429
|
||||
|
||||
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
|
||||
|
||||
# Save processor config to separate JSON file
|
||||
@@ -524,16 +576,6 @@ class CreateWatch(Resource):
|
||||
# worker_pool.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
|
||||
return {'uuid': new_uuid}, 201
|
||||
else:
|
||||
# Check if it was a limit issue
|
||||
page_watch_limit = os.getenv('PAGE_WATCH_LIMIT')
|
||||
if page_watch_limit:
|
||||
try:
|
||||
page_watch_limit = int(page_watch_limit)
|
||||
current_watch_count = len(self.datastore.data['watching'])
|
||||
if current_watch_count >= page_watch_limit:
|
||||
return f"Watch limit reached ({current_watch_count}/{page_watch_limit} watches). Cannot add more watches.", 429
|
||||
except ValueError:
|
||||
pass
|
||||
return "Invalid or unsupported URL", 400
|
||||
|
||||
@auth.check_token
|
||||
|
||||
@@ -42,7 +42,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
system_default_browser=browser_config.system_default_description(datastore),
|
||||
)
|
||||
|
||||
@add_watch_ui_blueprint.route("/snapshot", methods=['GET'])
|
||||
@add_watch_ui_blueprint.route("/snapshot", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def add_watch_ui_snapshot():
|
||||
"""One-shot live fetch of an arbitrary URL for the Add Watch visual selector.
|
||||
@@ -52,6 +52,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
connect, "Goto site", grab the screenshot + xpath element data, then tear
|
||||
the browser down again. Element selection then happens client-side on the
|
||||
returned data, exactly like the watch Edit page's visual selector.
|
||||
|
||||
POST-only and CSRF protected on purpose: this drives a real browser fetch and
|
||||
writes a temporary watch dir, so as a GET it could be triggered cross-origin
|
||||
(or by any tag/link that issues a GET) without the operator's consent.
|
||||
"""
|
||||
import base64
|
||||
from changedetectionio.blueprint.browser_steps import (
|
||||
@@ -71,7 +75,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# backslash/parser-differential rejection of GHSA-rph4-96w6-q594 (GHSA-56fq-63vj-9992).
|
||||
# Note this fetch never reaches difference_detection_processor.call_browser(), so it gets
|
||||
# no gating from there - it has to validate for itself.
|
||||
url = (request.args.get('url') or '').strip()
|
||||
url = (request.form.get('url') or '').strip()
|
||||
ok, reason = is_fetch_url_allowed(url)
|
||||
if not ok:
|
||||
logger.warning(f"Add-watch snapshot: refused '{url}' - {reason}")
|
||||
@@ -82,7 +86,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
# Either way it has to be able to render a preview - the plain HTTP client
|
||||
# produces no screenshot and no element data, so previewing with it is pointless
|
||||
# (and it used to be the silent default here, see the system-default bug).
|
||||
fetcher_name = (request.args.get('fetch_backend') or '').strip() or browser_config.default_visual_browser(datastore)
|
||||
fetcher_name = (request.form.get('fetch_backend') or '').strip() or browser_config.default_visual_browser(datastore)
|
||||
if not fetcher_name or not browser_config.is_visual_capable(fetcher_name, datastore):
|
||||
logger.warning(f"Add-watch snapshot: refused browser '{fetcher_name}' for '{url}'")
|
||||
return make_response('No interactive browser available that can render a live preview '
|
||||
|
||||
@@ -59,9 +59,18 @@ $(document).ready(() => {
|
||||
|
||||
$.ajax({
|
||||
url: add_watch_snapshot_url,
|
||||
// POST, never GET - this makes the server-side browser fetch a URL of our
|
||||
// choosing, so it must not be triggerable cross-origin. csrf.js adds the
|
||||
// X-CSRFToken header to every non-GET ajax call; the CSRF field on the form
|
||||
// is sent too so it works even if that handler hasn't run yet.
|
||||
method: 'POST',
|
||||
// Preview with the browser picked in the list - that same browser is what
|
||||
// gets saved on the watch, so what you see here is what it will check with.
|
||||
data: {url: url, fetch_backend: $('input[name="fetch_backend"]:checked').val() || ''},
|
||||
data: {
|
||||
url: url,
|
||||
fetch_backend: $('input[name="fetch_backend"]:checked').val() || '',
|
||||
csrf_token: $('#new-watch-form input[name="csrf_token"]').val() || '',
|
||||
},
|
||||
dataType: 'json',
|
||||
}).done((data) => {
|
||||
showState('ready');
|
||||
|
||||
@@ -98,7 +98,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
|
||||
backup_threads = []
|
||||
|
||||
@backups_blueprint.route("/request-backup", methods=['GET'])
|
||||
@backups_blueprint.route("/request-backup", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def request_backup():
|
||||
if any(thread.is_alive() for thread in backup_threads):
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<a class="pure-button pure-button-primary"
|
||||
href="{{ url_for('backups.request_backup') }}">{{ _('Create backup') }}</a>
|
||||
<form method="POST" action="{{ url_for('backups.request_backup') }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="pure-button pure-button-primary">{{ _('Create backup') }}</button>
|
||||
</form>
|
||||
{% if available_backups %}
|
||||
{# POST + CSRF token: this permanently deletes every backup archive, so it must
|
||||
not be reachable from a bare GET (an <img src=...> on any page the operator
|
||||
|
||||
@@ -296,7 +296,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
return browsersteps_start_session
|
||||
|
||||
|
||||
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
|
||||
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def browsersteps_start_session():
|
||||
# A new session was requested, return sessionID
|
||||
|
||||
@@ -100,7 +100,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
results = _recalc_check_status(uuid=uuid)
|
||||
return results
|
||||
|
||||
@check_proxies_blueprint.route("/<uuid_str:uuid>/start", methods=['GET'])
|
||||
@check_proxies_blueprint.route("/<uuid_str:uuid>/start", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def start_check(uuid):
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class Importer():
|
||||
self.good = 0
|
||||
self.remaining_data = []
|
||||
self.import_profile = None
|
||||
self.limit_reported = False
|
||||
|
||||
@abstractmethod
|
||||
def run(self,
|
||||
@@ -25,6 +26,21 @@ class Importer():
|
||||
datastore):
|
||||
pass
|
||||
|
||||
def watch_limit_hit(self, datastore, flash):
|
||||
"""True once PAGE_WATCH_LIMIT leaves no room - the caller must then stop importing.
|
||||
|
||||
Asked before each add_watch() so the limit is reported once for the whole file.
|
||||
add_watch() would otherwise flash the same error again for every remaining row.
|
||||
"""
|
||||
if not datastore.watch_limit_reached():
|
||||
return False
|
||||
|
||||
if not self.limit_reported:
|
||||
self.limit_reported = True
|
||||
flash(datastore.watch_limit_message(), 'error')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class import_url_list(Importer):
|
||||
"""
|
||||
@@ -44,7 +60,7 @@ class import_url_list(Importer):
|
||||
if (len(urls) > 5000):
|
||||
flash(gettext("Importing 5,000 of the first URLs from your list, the rest can be imported again."))
|
||||
|
||||
for url in urls:
|
||||
for idx, url in enumerate(urls):
|
||||
url = url.strip()
|
||||
if not len(url):
|
||||
continue
|
||||
@@ -59,6 +75,12 @@ class import_url_list(Importer):
|
||||
# Up to 5000 per batch so we dont flood the server
|
||||
# @todo validators.url will fail when you add your own IP etc
|
||||
if len(url) and 'http' in url.lower() and good < 5000:
|
||||
if self.watch_limit_hit(datastore, flash):
|
||||
# Hand back every line we didn't get to (originals, tags included) so they
|
||||
# land in the textarea to retry once there's room
|
||||
self.remaining_data.extend(u.strip() for u in urls[idx:] if u.strip())
|
||||
break
|
||||
|
||||
extras = None
|
||||
if processor:
|
||||
extras = {'processor': processor}
|
||||
@@ -107,6 +129,9 @@ class import_distill_io_json(Importer):
|
||||
extras = {'title': d.get('name', None)}
|
||||
|
||||
if len(d['uri']) and good < 5000:
|
||||
if self.watch_limit_hit(datastore, flash):
|
||||
break
|
||||
|
||||
try:
|
||||
# @todo we only support CSS ones at the moment
|
||||
if d_config['selections'][0]['frames'][0]['excludes'][0]['type'] == 'css':
|
||||
@@ -200,6 +225,9 @@ class import_xlsx_wachete(Importer):
|
||||
# Don't bother processing anything else on this row
|
||||
continue
|
||||
|
||||
if self.watch_limit_hit(datastore, flash):
|
||||
break
|
||||
|
||||
new_uuid = datastore.add_watch(url=data['url'].strip(),
|
||||
extras=extras,
|
||||
tag=data.get('folder'),
|
||||
@@ -281,6 +309,9 @@ class import_xlsx_custom(Importer):
|
||||
|
||||
# At minimum a URL is required.
|
||||
if url:
|
||||
if self.watch_limit_hit(datastore, flash):
|
||||
break
|
||||
|
||||
new_uuid = datastore.add_watch(url=url,
|
||||
extras=extras,
|
||||
tag=tags,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Left-rail (action sidebar) display modes.
|
||||
|
||||
A leaf module on purpose: forms.py, flask_app.py and model/App.py all need these, and
|
||||
model/App.py seeding its default from forms.py would drag the whole form stack (~550
|
||||
modules) into the model layer.
|
||||
"""
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
# The complete set of left-rail modes - flask_app.get_sidebar_mode_class() maps these
|
||||
# (and only these) onto body classes, so a new mode here needs a new mapping there.
|
||||
MENU_SIDEBAR_ACTIONMODES = [
|
||||
('expandable', _l('Expand on hover')), # Slim icon rail that expands on hover/focus
|
||||
('pinned-expanded', _l('Always expanded')), # Always expanded, never collapses
|
||||
('minimal', _l('Stays minimal')), # Always small, never expands
|
||||
]
|
||||
MENU_SIDEBAR_ACTIONMODES_DEFAULT = 'expandable'
|
||||
@@ -14,7 +14,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
|
||||
|
||||
price_data_follower_blueprint = Blueprint('price_data_follower', __name__)
|
||||
|
||||
@price_data_follower_blueprint.route("/<uuid_str:uuid>/accept", methods=['GET'])
|
||||
@price_data_follower_blueprint.route("/<uuid_str:uuid>/accept", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def accept(uuid):
|
||||
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT
|
||||
@@ -24,7 +24,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
|
||||
worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
|
||||
return redirect(url_for("watchlist.index"))
|
||||
|
||||
@price_data_follower_blueprint.route("/<uuid_str:uuid>/reject", methods=['GET'])
|
||||
@price_data_follower_blueprint.route("/<uuid_str:uuid>/reject", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def reject(uuid):
|
||||
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT
|
||||
|
||||
@@ -261,6 +261,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
llm_show_costs=llm_show_costs,
|
||||
python_version=python_version,
|
||||
uptime_seconds=uptime_seconds,
|
||||
# None unless PAGE_WATCH_LIMIT is set, which hides the row entirely
|
||||
watch_limit=datastore.watch_limit,
|
||||
available_timezones=sorted(available_timezones()),
|
||||
emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
|
||||
extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(),
|
||||
@@ -276,7 +278,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
return output
|
||||
|
||||
@settings_blueprint.route("/reset-api-key", methods=['GET'])
|
||||
@settings_blueprint.route("/reset-api-key", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def settings_reset_api_key():
|
||||
secret = secrets.token_hex(16)
|
||||
@@ -293,7 +295,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."])
|
||||
return output
|
||||
|
||||
@settings_blueprint.route("/toggle-all-paused", methods=['GET'])
|
||||
@settings_blueprint.route("/toggle-all-paused", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def toggle_all_paused():
|
||||
current_state = datastore.data['settings']['application'].get('all_paused', False)
|
||||
@@ -307,7 +309,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
@settings_blueprint.route("/toggle-all-muted", methods=['GET'])
|
||||
@settings_blueprint.route("/toggle-all-muted", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def toggle_all_muted():
|
||||
current_state = datastore.data['settings']['application'].get('all_muted', False)
|
||||
|
||||
@@ -130,7 +130,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.exception("LLM model list full traceback:")
|
||||
return jsonify({'models': [], 'error': str(e)}), 400
|
||||
|
||||
@llm_blueprint.route("/test", methods=['GET'])
|
||||
@llm_blueprint.route("/test", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def llm_test():
|
||||
from flask import request
|
||||
|
||||
@@ -208,7 +208,7 @@ nav
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">{{ _('Regenerate API key') }}</a>
|
||||
<button type="submit" formmethod="post" formnovalidate formaction="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">{{ _('Regenerate API key') }}</button>
|
||||
</div>
|
||||
<div class="pure-control-group">
|
||||
<h4>{{ _('Chrome Extension') }}</h4>
|
||||
@@ -391,6 +391,9 @@ nav
|
||||
<div class="tab-pane-inner" id="info">
|
||||
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
|
||||
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>
|
||||
{% if watch_limit is not none %}
|
||||
<p><strong>{{ _('Maximum number of page watches:') }}</strong> {{ watch_limit }}</p>
|
||||
{% endif %}
|
||||
<p><strong>{{ _('Plugins active:') }}</strong></p>
|
||||
{% if active_plugins %}
|
||||
<ul>
|
||||
|
||||
@@ -577,7 +577,10 @@
|
||||
if (mult.trim()) params.set('local_token_multiplier', mult.trim());
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params);
|
||||
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': csrftoken}
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
result.style.cssText = 'display:block; background:rgba(39,174,96,0.08); border:1px solid rgba(39,174,96,0.3); border-radius:5px; padding:0.6em 0.85em; font-size:0.88em; line-height:1.45;';
|
||||
|
||||
@@ -62,7 +62,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
return redirect(url_for('tags.tags_overview_page'))
|
||||
|
||||
@tags_blueprint.route("/mute/<uuid_str:uuid>", methods=['GET'])
|
||||
@tags_blueprint.route("/mute/<uuid_str:uuid>", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def mute(uuid):
|
||||
tag = datastore.data['settings']['application']['tags'].get(uuid)
|
||||
@@ -189,6 +189,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
'watch': default,
|
||||
'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
|
||||
'llm_configured': bool(_get_llm_config(datastore)),
|
||||
# Tells the shared AI include it is rendering a group, not a watch — `watch` above
|
||||
# is this tag, so it cannot be used to tell the two apart.
|
||||
'llm_group_edit': True,
|
||||
}
|
||||
|
||||
included_content = {}
|
||||
|
||||
@@ -10,6 +10,7 @@ from wtforms.fields.simple import BooleanField
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
from changedetectionio.blueprint.tags.colour import CSS_HEX_COLOUR_REGEX
|
||||
from changedetectionio.widgets.ternary_boolean import TernaryNoneBooleanField
|
||||
from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form
|
||||
from changedetectionio.llm.ui_strings import LLM_INTENT_TAG_PLACEHOLDER
|
||||
from changedetectionio.llm.evaluator import (
|
||||
@@ -28,7 +29,22 @@ class group_restock_settings_form(restock_settings_form):
|
||||
validators=[validators.Optional(),
|
||||
validators.Regexp(CSS_HEX_COLOUR_REGEX,
|
||||
message=_l('Must be a hex colour, for example #4f8ef7'))])
|
||||
llm_intent = TextAreaField('AI Change Intent',
|
||||
# The group's one and only AI control. Same key as the per-watch switch (see forms.py) but
|
||||
# ternary, because a group has a third useful answer: "no opinion, leave it to each watch"
|
||||
# (the default). See tag_llm_decision() for the semantics of each state — #4204.
|
||||
# @NOTE! In the near future this stops being a ternary bool and becomes a *profile*
|
||||
# selector — pick one of the configured LLM profiles, or 'off', or inherit. The
|
||||
# field name is already the future one; only the widget and the True/False/None
|
||||
# value space need to change, so keep reads going through tag_llm_decision().
|
||||
llm_backend_profile = TernaryNoneBooleanField(
|
||||
_l('AI for watches in this group'),
|
||||
default=None,
|
||||
yes_text=_l('On, use the settings below'),
|
||||
no_text=_l('Off for every watch'),
|
||||
none_text=_l('Leave it to each watch'),
|
||||
)
|
||||
|
||||
llm_intent = TextAreaField('AI Change Intent - Notify me when..',
|
||||
validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
render_kw={"rows": "5", "placeholder": LLM_INTENT_TAG_PLACEHOLDER})
|
||||
|
||||
@@ -38,7 +54,7 @@ class group_restock_settings_form(restock_settings_form):
|
||||
default='')
|
||||
|
||||
llm_change_summary_mode = RadioField(
|
||||
_l('How this prompt combines with the inherited one'),
|
||||
_l('Change Summary prompt - Append or Replace the default?'),
|
||||
choices=[
|
||||
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
|
||||
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
<div class="tabs collapsable">
|
||||
<ul>
|
||||
<li class="tab" id=""><a href="#general">{{ _('General') }}</a></li>
|
||||
{% if llm_configured %}
|
||||
{# Always shown, like the watch edit page: with no provider configured the pane
|
||||
explains how to set one up (and carries the AI switches through on save). #}
|
||||
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
|
||||
{% endif %}
|
||||
<li class="tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
|
||||
{% if extra_tab_content %}
|
||||
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
|
||||
@@ -92,11 +92,9 @@
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{% if llm_configured %}
|
||||
<div class="tab-pane-inner" id="ai-llm">
|
||||
{% include "edit/include_llm_intent.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="tab-pane-inner" id="filters-and-triggers">
|
||||
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
|
||||
|
||||
@@ -68,7 +68,10 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
|
||||
{#-{{ loop.cycle('pure-table-odd', 'pure-table-even') }}-#}
|
||||
<tr id="{{ uuid }}" class="">
|
||||
<td class="watch-controls">
|
||||
<a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}" aria-label="{{ _('Mute notifications') }}" title="{{ _('Mute notifications') }}"><i data-feather="{{ 'bell-off' if tag.notification_muted else 'bell' }}" class="icon icon-mute"></i></a>
|
||||
<form method="POST" action="{{url_for('tags.mute', uuid=tag.uuid)}}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="bare-btn link-mute state-{{'on' if tag.notification_muted else 'off'}}" aria-label="{{ _('Mute notifications') }}" title="{{ _('Mute notifications') }}"><i data-feather="{{ 'bell-off' if tag.notification_muted else 'bell' }}" class="icon icon-mute"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="watch-count">{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
|
||||
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}" class="watch-tag-list tag-{{ tag.title|sanitize_tag_class }}">{{ tag.title }}</a></td>
|
||||
|
||||
@@ -293,6 +293,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
|
||||
uuid = list(datastore.data['watching'].keys()).pop()
|
||||
|
||||
new_uuid = datastore.clone(uuid)
|
||||
if not new_uuid:
|
||||
# Refused (e.g. PAGE_WATCH_LIMIT) - the reason is already flashed
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
if not datastore.data['watching'].get(uuid).get('paused'):
|
||||
worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=5, item={'uuid': new_uuid}))
|
||||
@@ -404,7 +407,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
|
||||
@ui_blueprint.route("/share-url/<uuid_str:uuid>", methods=['GET'])
|
||||
@ui_blueprint.route("/share-url/<uuid_str:uuid>", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def form_share_put_watch(uuid):
|
||||
"""Given a watch UUID, upload the info and return a share-link
|
||||
@@ -452,7 +455,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool,
|
||||
|
||||
return redirect(url_for('watchlist.index'))
|
||||
|
||||
@ui_blueprint.route("/language/auto-detect", methods=['GET'])
|
||||
@ui_blueprint.route("/language/auto-detect", methods=['POST'])
|
||||
def delete_locale_language_session_var_if_it_exists():
|
||||
"""Clear the session locale preference to auto-detect from browser Accept-Language header"""
|
||||
if 'locale' in session:
|
||||
|
||||
@@ -8,30 +8,57 @@ from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
from changedetectionio.time_handler import is_within_schedule
|
||||
from changedetectionio.time_handler import default_timezone_name, is_within_schedule
|
||||
from changedetectionio import worker_pool
|
||||
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
|
||||
|
||||
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
|
||||
edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates")
|
||||
|
||||
def _watch_tags(watch):
|
||||
"""(uuid, tag) for this watch's tags, in its own tag order, skipping UUIDs we don't know."""
|
||||
tags = datastore.data['settings']['application'].get('tags', {})
|
||||
return [(tag_uuid, tags[tag_uuid]) for tag_uuid in watch.get('tags', []) if tag_uuid in tags]
|
||||
|
||||
def _resolve_llm_group_overrides(watch, datastore) -> dict:
|
||||
"""
|
||||
For each LLM field (llm_intent, llm_change_summary): if the watch has no own
|
||||
value but a linked tag does, return {'value': ..., 'group_name': ...} so the
|
||||
edit template can render the textarea as readonly with a group-sourced placeholder.
|
||||
Returns None for each field when the watch has its own value (editable).
|
||||
value but a linked group does, return {'value': ..., 'group_name': ..., 'group_uuid': ...}
|
||||
so the edit template can show the inherited value as the textarea placeholder and link
|
||||
back to the group that supplied it.
|
||||
Returns None for each field when the watch has its own value (nothing inherited).
|
||||
|
||||
Only groups whose AI setting is "On" lend their prompts — the same gate the evaluator
|
||||
applies via resolve_llm_field(), so the placeholder always reflects what will actually
|
||||
run. See llm/evaluator.py:tag_llm_decision().
|
||||
"""
|
||||
result = {'llm_intent': None, 'llm_change_summary': None}
|
||||
from changedetectionio.llm.evaluator import tag_llm_applies_to_watches, tag_llm_decision
|
||||
|
||||
result = {'llm_intent': None, 'llm_change_summary': None, 'llm_backend_profile': None}
|
||||
|
||||
# AI on/off is not a "fill in the blank" field: a group that has taken the decision
|
||||
# (On or Off, i.e. not "leave it to each watch") decides for every watch in it (#4204),
|
||||
# so report it and let the template show the watch's own checkbox as overridden.
|
||||
for tag_uuid, tag in _watch_tags(watch):
|
||||
if tag_llm_decision(tag) is not None:
|
||||
result['llm_backend_profile'] = {
|
||||
'value': tag_llm_decision(tag),
|
||||
'group_name': tag.get('title', 'tag'),
|
||||
'group_uuid': tag_uuid,
|
||||
}
|
||||
break
|
||||
|
||||
for field in ('llm_intent', 'llm_change_summary'):
|
||||
if (watch.get(field) or '').strip():
|
||||
continue # watch has its own value — editable, no group override
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag and (tag.get(field) or '').strip():
|
||||
for tag_uuid, tag in _watch_tags(watch):
|
||||
if not tag_llm_applies_to_watches(tag):
|
||||
continue
|
||||
if (tag.get(field) or '').strip():
|
||||
result[field] = {
|
||||
'value': tag.get(field).strip(),
|
||||
'group_name': tag.get('title', 'tag'),
|
||||
'group_uuid': tag_uuid,
|
||||
}
|
||||
break
|
||||
return result
|
||||
@@ -211,6 +238,16 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
extra_update_obj['filter_text_replaced'] = True
|
||||
extra_update_obj['filter_text_removed'] = True
|
||||
|
||||
# A group that has taken the AI on/off decision owns that control, so the edit page
|
||||
# renders it disabled (see include_llm_intent.html). A disabled checkbox isn't
|
||||
# submitted at all, and for a checkbox "not submitted" is indistinguishable from
|
||||
# "unticked" — so don't take this field from the form while a group decides. The
|
||||
# watch keeps its own preference untouched, ready for when the group stops deciding.
|
||||
# Resolved against the watch's *stored* tags — i.e. what the page was rendered from,
|
||||
# so attaching or detaching a group in this same save is still honoured correctly.
|
||||
if _resolve_llm_group_overrides(datastore.data['watching'][uuid], datastore).get('llm_backend_profile'):
|
||||
extra_update_obj['llm_backend_profile'] = datastore.data['watching'][uuid].get('llm_backend_profile', True)
|
||||
|
||||
# Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs
|
||||
tag_uuids = []
|
||||
if form.data.get('tags'):
|
||||
@@ -253,13 +290,14 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
watch = datastore.data['watching'].get(uuid)
|
||||
|
||||
if watch.get('time_between_check_use_default'):
|
||||
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})
|
||||
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit') or {}
|
||||
else:
|
||||
time_schedule_limit = watch.get('time_schedule_limit')
|
||||
time_schedule_limit = watch.get('time_schedule_limit') or {}
|
||||
|
||||
tz_name = time_schedule_limit.get('timezone')
|
||||
if not tz_name:
|
||||
tz_name = datastore.data['settings']['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC').strip())
|
||||
tz_name = default_timezone_name(
|
||||
time_schedule_limit.get('timezone')
|
||||
or datastore.data['settings']['application'].get('scheduler_timezone_default')
|
||||
)
|
||||
|
||||
if time_schedule_limit and time_schedule_limit.get('enabled'):
|
||||
try:
|
||||
@@ -267,9 +305,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
default_tz=tz_name
|
||||
)
|
||||
except Exception as e:
|
||||
# Only decides whether to queue an immediate recheck — the watch is
|
||||
# already saved by this point. Returning a bare `False` from a view
|
||||
# made Flask raise TypeError and the save appeared to fail with a 500.
|
||||
logger.error(
|
||||
f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
|
||||
return False
|
||||
is_in_schedule = False
|
||||
|
||||
#############################
|
||||
if not datastore.data['watching'][uuid].get('paused') and is_in_schedule:
|
||||
@@ -469,15 +510,22 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
|
||||
uuid = request.args.get('uuid','')
|
||||
if datastore.data["watching"].get(uuid):
|
||||
# Build the new list and REBIND it. Appending in place bypasses
|
||||
# watch_base.__setitem__, so the watch is never flagged as edited and
|
||||
# the "content unchanged since last check" skip stays active — the new
|
||||
# ignore_text would then not take effect until the page changed on its
|
||||
# own. Assigning the key marks the watch edited and forces reprocessing.
|
||||
ignore_text = list(datastore.data["watching"][uuid]['ignore_text'])
|
||||
if mode == 'exact':
|
||||
for l in selection.splitlines():
|
||||
datastore.data["watching"][uuid]['ignore_text'].append(l.strip())
|
||||
ignore_text.append(l.strip())
|
||||
elif mode == 'digit-regex':
|
||||
for l in selection.splitlines():
|
||||
# Replace any series of numbers with a regex
|
||||
s = re.escape(l.strip())
|
||||
s = re.sub(r'[0-9]+', r'\\d+', s)
|
||||
datastore.data["watching"][uuid]['ignore_text'].append('/' + s + '/')
|
||||
ignore_text.append('/' + s + '/')
|
||||
datastore.data["watching"][uuid]['ignore_text'] = ignore_text
|
||||
|
||||
# Save the updated ignore_text
|
||||
datastore.data["watching"][uuid].commit()
|
||||
|
||||
@@ -250,7 +250,7 @@ Math: {{ 1 + 1 }}") }}
|
||||
<div class="flex-wrapper" >
|
||||
|
||||
<div id="browser-steps-ui" class="noselect">
|
||||
<div class="noselect" id="browsersteps-selector-wrapper" style="width: 100%">
|
||||
<div class="noselect" id="browsersteps-selector-wrapper">
|
||||
<span class="loader" >
|
||||
<span id="browsersteps-click-start">
|
||||
<h2 >{{ _('Click here to Start') }}</h2>
|
||||
|
||||
@@ -18,6 +18,24 @@ from changedetectionio.blueprint.watchlist.row_context import watch_row_context
|
||||
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
|
||||
watchlist_blueprint = Blueprint('watchlist', __name__, template_folder="templates")
|
||||
|
||||
@watchlist_blueprint.route("/toggle", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def toggle():
|
||||
op = request.args.get('op')
|
||||
uuid = request.args.get('uuid')
|
||||
watch = datastore.data['watching'].get(uuid)
|
||||
|
||||
if not watch:
|
||||
flash(_('Watch not found'), 'error')
|
||||
else:
|
||||
if op == 'pause':
|
||||
watch.toggle_pause()
|
||||
elif op == 'mute':
|
||||
watch.toggle_mute()
|
||||
watch.commit()
|
||||
|
||||
return redirect(url_for('watchlist.index', tag=request.args.get('tag')))
|
||||
|
||||
@watchlist_blueprint.route("/", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def index():
|
||||
@@ -36,17 +54,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
if request.args.get('rss'):
|
||||
return redirect(url_for('rss.feed', tag=active_tag_uuid))
|
||||
|
||||
op = request.args.get('op')
|
||||
if op:
|
||||
uuid = request.args.get('uuid')
|
||||
if op == 'pause':
|
||||
datastore.data['watching'][uuid].toggle_pause()
|
||||
elif op == 'mute':
|
||||
datastore.data['watching'][uuid].toggle_mute()
|
||||
|
||||
datastore.data['watching'][uuid].commit()
|
||||
return redirect(url_for('watchlist.index', tag = active_tag_uuid))
|
||||
|
||||
# Sort by last_changed and add the uuid which is usually the key..
|
||||
sorted_watches = []
|
||||
active_processor = request.args.get('processor', '').strip()
|
||||
|
||||
@@ -35,10 +35,12 @@
|
||||
<td class="inline checkbox-uuid" ><div><input name="uuids" type="checkbox" value="{{ watch.uuid}} " >{# <span class="counter-i">{{ loop.index+pagination.skip }}</span>#}</div></td>
|
||||
<td class="inline watch-controls">
|
||||
<div>
|
||||
<a class="ajax-op state-off pause-toggle" data-op="pause" aria-label="{{ _('Pause checks') }}" title="{{ _('Pause checks') }}" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="pause" class="icon icon-pause"></i></a>
|
||||
<a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" aria-label="{{ _('UnPause checks') }}" title="{{ _('UnPause checks') }}" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="play" class="icon icon-unpause"></i></a>
|
||||
<a class="ajax-op state-off mute-toggle" data-op="mute" aria-label="{{ _('Mute notification') }}" title="{{ _('Mute notification') }}" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="bell" class="icon icon-mute"></i></a>
|
||||
<a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" aria-label="{{ _('UnMute notification') }}" title="{{ _('UnMute notification') }}" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><i data-feather="bell-off" class="icon icon-mute"></i></a>
|
||||
{%- set pause_action = url_for('watchlist.toggle', op='pause', uuid=watch.uuid, tag=active_tag_uuid) -%}
|
||||
{%- set mute_action = url_for('watchlist.toggle', op='mute', uuid=watch.uuid, tag=active_tag_uuid) -%}
|
||||
<button type="submit" formmethod="post" formaction="{{ pause_action }}" class="bare-btn ajax-op state-off pause-toggle" data-op="pause" aria-label="{{ _('Pause checks') }}" title="{{ _('Pause checks') }}"><i data-feather="pause" class="icon icon-pause"></i></button>
|
||||
<button type="submit" formmethod="post" formaction="{{ pause_action }}" class="bare-btn ajax-op state-on pause-toggle" data-op="pause" style="display: none" aria-label="{{ _('UnPause checks') }}" title="{{ _('UnPause checks') }}"><i data-feather="play" class="icon icon-unpause"></i></button>
|
||||
<button type="submit" formmethod="post" formaction="{{ mute_action }}" class="bare-btn ajax-op state-off mute-toggle" data-op="mute" aria-label="{{ _('Mute notification') }}" title="{{ _('Mute notification') }}"><i data-feather="bell" class="icon icon-mute"></i></button>
|
||||
<button type="submit" formmethod="post" formaction="{{ mute_action }}" class="bare-btn ajax-op state-on mute-toggle" data-op="mute" style="display: none" aria-label="{{ _('UnMute notification') }}" title="{{ _('UnMute notification') }}"><i data-feather="bell-off" class="icon icon-mute"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -81,13 +83,13 @@
|
||||
<div class="error-text" style="display:none;">{{ error_texts|safe }}</div>
|
||||
{%- if watch['processor'] == 'text_json_diff' -%}
|
||||
{%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%}
|
||||
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
|
||||
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <button type="submit" formmethod="post" formaction="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</button> <button type="submit" formmethod="post" formaction="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="bare-btn bare-btn--link">No</button></div>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
</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>
|
||||
<button type="submit" formmethod="post" formaction="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}" class="bare-btn link-spread"><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') }}" ></button>
|
||||
{%- set effective_fetcher = watch.get_fetch_backend if watch.get_fetch_backend != "system" else system_default_fetcher -%}
|
||||
{%- if effective_fetcher and ("html_webdriver" in effective_fetcher or "html_" in effective_fetcher or "extra_browser_" in effective_fetcher) -%}
|
||||
{{ effective_fetcher|fetcher_status_icons }}
|
||||
@@ -163,7 +165,9 @@
|
||||
<div>
|
||||
{%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%}
|
||||
<a href="" class="already-in-queue-button recheck cdio-btn cdio-btn--primary cdio-btn--sm" style="display: none;" disabled="disabled"><i data-feather="clock"></i>{{ _('Queued') }}</a>
|
||||
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck cdio-btn cdio-btn--primary cdio-btn--sm"><i data-feather="refresh-cw"></i>{{ _('Recheck') }}</a>
|
||||
<button type="submit" formmethod="post"
|
||||
formaction="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}"
|
||||
data-op='recheck' class="ajax-op recheck cdio-btn cdio-btn--primary cdio-btn--sm"><i data-feather="refresh-cw"></i>{{ _('Recheck') }}</button>
|
||||
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="cdio-btn cdio-btn--primary cdio-btn--sm">{{ _('Edit') }}</a>
|
||||
<a href="{{ url_for('ui.ui_diff.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="cdio-btn cdio-btn--primary cdio-btn--sm history-link ai-history-btn" style="display: none;" data-uuid="{{ watch.uuid }}" data-summary-url="{{ url_for('ui.ui_diff.diff_llm_summary', uuid=watch.uuid) }}" data-processor-data-url="{{ url_for('ui.ui_diff.diff_history_page_processor_data', uuid=watch.uuid) }}"><span class="btn-label-history">{{ _('History') }}</span><span class="btn-label-summary">✨ {{ _('Summary') }}</span></a>
|
||||
<a href="{{ url_for('ui.ui_preview.preview_page', uuid=watch.uuid)}}" {{target_attr}} class="cdio-btn cdio-btn--primary cdio-btn--sm preview-link" style="display: none;">{{ _('Preview') }}</a>
|
||||
|
||||
@@ -34,7 +34,13 @@ CUSTOM_OPERATIONS = {
|
||||
def filter_complete_rules(ruleset):
|
||||
rules = [
|
||||
rule for rule in ruleset
|
||||
if all(value not in ("", False, "None", None) for value in [rule["operator"], rule["field"], rule["value"]])
|
||||
if all(
|
||||
rule.get(k) is not None
|
||||
and rule.get(k) is not False
|
||||
and rule.get(k) != ""
|
||||
and rule.get(k) != "None"
|
||||
for k in ("operator", "field", "value")
|
||||
)
|
||||
]
|
||||
return rules
|
||||
|
||||
@@ -54,12 +60,20 @@ def convert_to_jsonlogic(logic_operator: str, rule_dict: list):
|
||||
field = condition["field"]
|
||||
value = condition["value"]
|
||||
|
||||
if not operator or operator == 'None' or not value or not field:
|
||||
if (
|
||||
not operator
|
||||
or operator == 'None'
|
||||
or not field
|
||||
or value is None
|
||||
or value is False
|
||||
or value == ""
|
||||
or value == "None"
|
||||
):
|
||||
raise EmptyConditionRuleRowNotUsable()
|
||||
|
||||
# Convert value to int/float if possible
|
||||
try:
|
||||
if isinstance(value, str) and "." in value and str != "None":
|
||||
if isinstance(value, str) and "." in value and value != "None":
|
||||
value = float(value)
|
||||
else:
|
||||
value = int(value)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import re
|
||||
|
||||
import pluggy
|
||||
from price_parser import Price
|
||||
from loguru import logger
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
@@ -70,6 +69,7 @@ def register_field_choices():
|
||||
@hookimpl
|
||||
def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
|
||||
|
||||
from price_parser import Price
|
||||
res = {}
|
||||
if 'text' in ephemeral_data:
|
||||
res['page_filtered_text'] = ephemeral_data['text']
|
||||
|
||||
+501
-207
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ from loguru import logger
|
||||
from wtforms.widgets.core import TimeInput
|
||||
from flask_babel import lazy_gettext as _l, gettext
|
||||
|
||||
from changedetectionio.blueprint.menu_modes import MENU_SIDEBAR_ACTIONMODES, MENU_SIDEBAR_ACTIONMODES_DEFAULT
|
||||
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_TEMPLATE_TYPE_OPTIONS, RSS_TEMPLATE_HTML_DEFAULT
|
||||
from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER
|
||||
from changedetectionio.llm.evaluator import (
|
||||
@@ -696,14 +697,14 @@ class ValidateCSSJSONXPATHInput(object):
|
||||
raise ValidationError("XPath not permitted in this field!")
|
||||
from lxml import etree, html
|
||||
import elementpath
|
||||
from changedetectionio.html_tools import SafeXPath3Parser, lxml_guard, lxml_html_parser
|
||||
from changedetectionio.html_tools import get_safe_xpath3_parser, lxml_guard, lxml_html_parser
|
||||
line = line.replace('xpath:', '')
|
||||
|
||||
try:
|
||||
# Runs on a Flask request thread - must share the worker's lxml lock.
|
||||
with lxml_guard():
|
||||
tree = html.fromstring("<html></html>", parser=lxml_html_parser())
|
||||
elementpath.select(tree, line.strip(), parser=SafeXPath3Parser)
|
||||
elementpath.select(tree, line.strip(), parser=get_safe_xpath3_parser())
|
||||
except elementpath.ElementPathError as e:
|
||||
message = field.gettext('\'%(expression)s\' is not a valid XPath expression. (%(error)s)')
|
||||
raise ValidationError(message % {'expression': line, 'error': str(e)})
|
||||
@@ -953,7 +954,7 @@ class processor_text_json_diff_form(commonSettingsForm):
|
||||
|
||||
time_between_check_use_default = BooleanField(_l('Use global settings for time between check and scheduler.'), default=False)
|
||||
|
||||
llm_intent = TextAreaField(_l('AI Change Intent'), validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
llm_intent = TextAreaField(_l('AI Change Intent - Notify me when..'), validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
render_kw={"rows": "5", "placeholder": LLM_INTENT_WATCH_PLACEHOLDER})
|
||||
|
||||
llm_change_summary = TextAreaField(_l('AI Change Summary'), validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
@@ -961,13 +962,16 @@ class processor_text_json_diff_form(commonSettingsForm):
|
||||
default='')
|
||||
|
||||
llm_change_summary_mode = RadioField(
|
||||
_l('How this prompt combines with the inherited one'),
|
||||
_l('Change Summary prompt - Append or Replace the default?'),
|
||||
choices=[
|
||||
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
|
||||
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
|
||||
],
|
||||
default=LLM_PROMPT_MODE_REPLACE,
|
||||
)
|
||||
# @NOTE! In the near future you should be able to select which LLM profile *OR* "off"/None for this watch/group
|
||||
# For now we use the 'future' field naming but keep the functionality simple.
|
||||
llm_backend_profile = BooleanField(_l('AI enabled for this watch?'), default=True)
|
||||
|
||||
include_filters = StringListField(_l('CSS/JSONPath/JQ/XPath Filters'), [ValidateCSSJSONXPATHInput()], default='')
|
||||
|
||||
@@ -1161,9 +1165,8 @@ class globalSettingsApplicationUIForm(Form):
|
||||
choices=[('long', _l('Long (1 minute ago)')), ('short', _l('Short (1m ago)'))],
|
||||
default='long', validators=[validators.Optional()])
|
||||
sidebar_mode = SelectField(_l('Navigation sidebar'),
|
||||
choices=[('collapsed', _l('Collapsed icon rail (expands on hover)')),
|
||||
('pinned', _l('Always expanded'))],
|
||||
default='collapsed', validators=[validators.Optional()])
|
||||
choices=MENU_SIDEBAR_ACTIONMODES,
|
||||
default=MENU_SIDEBAR_ACTIONMODES_DEFAULT, validators=[validators.Optional()])
|
||||
|
||||
# datastore.data['settings']['application']..
|
||||
class globalSettingsApplicationForm(commonSettingsForm):
|
||||
|
||||
@@ -164,7 +164,7 @@ _DEFAULT_UNSAFE_XPATH3_FUNCTIONS = [
|
||||
]
|
||||
|
||||
|
||||
def _build_safe_xpath3_parser():
|
||||
def get_safe_xpath3_parser():
|
||||
"""Return an XPath3Parser subclass with filesystem/environment access functions removed.
|
||||
|
||||
XPath 3.0 includes functions that can read arbitrary files or environment variables:
|
||||
@@ -196,9 +196,6 @@ def _build_safe_xpath3_parser():
|
||||
return SafeXPath3Parser
|
||||
|
||||
|
||||
# Module-level singleton — built once, reused everywhere.
|
||||
SafeXPath3Parser = _build_safe_xpath3_parser()
|
||||
|
||||
# Doesn't look like python supports forward slash auto enclosure in re.findall
|
||||
# So convert it to inline flag "(?i)foobar" type configuration
|
||||
@lru_cache(maxsize=100)
|
||||
@@ -386,7 +383,7 @@ def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False
|
||||
# This allows //title to match elements in the default namespace
|
||||
namespaces[''] = tree.nsmap[None]
|
||||
|
||||
r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, parser=SafeXPath3Parser)
|
||||
r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, parser=get_safe_xpath3_parser())
|
||||
#@note: //title/text() now works with default namespaces (fixed by registering '' prefix)
|
||||
#@note: //title/text() wont work where <title>CDATA.. (use cdata_in_document_to_text first)
|
||||
|
||||
@@ -508,11 +505,17 @@ def _has_lone_surrogate(value):
|
||||
return any(_has_lone_surrogate(v) for v in value)
|
||||
return False
|
||||
|
||||
def _sanitize_lone_surrogate_str(value: str) -> str:
|
||||
return _LONE_SURROGATE_RE.sub('\ufffd', value)
|
||||
|
||||
def _sanitize_lone_surrogates(value):
|
||||
if isinstance(value, str):
|
||||
return _LONE_SURROGATE_RE.sub('\ufffd', value)
|
||||
return _sanitize_lone_surrogate_str(value)
|
||||
if isinstance(value, dict):
|
||||
return {_sanitize_lone_surrogates(k): _sanitize_lone_surrogates(v) for k, v in value.items()}
|
||||
# JSON object keys are always strings, so they only need the substitution - recursing on a
|
||||
# key would (in theory) hand back an unhashable dict/list
|
||||
return {_sanitize_lone_surrogate_str(k) if isinstance(k, str) else k: _sanitize_lone_surrogates(v)
|
||||
for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_lone_surrogates(v) for v in value]
|
||||
return value
|
||||
|
||||
@@ -80,7 +80,6 @@ Note:
|
||||
This extension uses the Arrow library for timezone-aware datetime handling.
|
||||
All timezone names should be valid IANA timezone identifiers (e.g., 'America/New_York').
|
||||
"""
|
||||
import arrow
|
||||
|
||||
from jinja2 import nodes
|
||||
from jinja2.ext import Extension
|
||||
@@ -125,6 +124,7 @@ class TimeExtension(Extension):
|
||||
_datetime('UTC', '+', 'hours=2,minutes=30', '%Y-%m-%d %H:%M:%S')
|
||||
# Returns current time + 2.5 hours
|
||||
"""
|
||||
import arrow
|
||||
# Use default timezone if none specified
|
||||
if not timezone or timezone == '':
|
||||
timezone = self.environment.default_timezone
|
||||
@@ -162,6 +162,7 @@ class TimeExtension(Extension):
|
||||
_now('America/New_York', '%Y-%m-%d %H:%M:%S')
|
||||
# Returns current time in New York timezone
|
||||
"""
|
||||
import arrow
|
||||
# Use default timezone if none specified
|
||||
if not timezone or timezone == '':
|
||||
timezone = self.environment.default_timezone
|
||||
|
||||
@@ -6,6 +6,7 @@ and makes the call easy to mock in tests.
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Default output token cap for JSON-returning calls (intent eval, preview, setup).
|
||||
@@ -24,6 +25,9 @@ DEFAULT_TIMEOUT = int(os.getenv('LLM_TIMEOUT', 300))
|
||||
# longer deadline (Hermes-style, 30 min). Overridable via LLM_LOCAL_TIMEOUT; see
|
||||
# evaluator.resolve_llm_timeout() for how the endpoint is classified.
|
||||
DEFAULT_LOCAL_TIMEOUT = int(os.getenv('LLM_LOCAL_TIMEOUT', 1800))
|
||||
# Models and reasoning architectures that reject explicit sampling parameters (temperature/top_p)
|
||||
_NO_TEMPERATURE_MODEL_KEYWORDS = ('flash-lite', 'thinking-exp', 'o1', 'o3', 'o4')
|
||||
|
||||
DEFAULT_RETRIES = 3
|
||||
|
||||
|
||||
@@ -63,10 +67,16 @@ def _install_litellm_debug():
|
||||
logger.info("LLM client: litellm debug logging routed through loguru")
|
||||
|
||||
|
||||
def completion(model: str, messages: list, api_key: str = None,
|
||||
api_base: str = None, timeout: int = DEFAULT_TIMEOUT,
|
||||
max_tokens: int = None, extra_body: dict = None,
|
||||
debug: bool = False) -> tuple[str, int, int, int]:
|
||||
def completion( # noqa: C901
|
||||
model: str,
|
||||
messages: list,
|
||||
api_key: str = None,
|
||||
api_base: str = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
max_tokens: int = None,
|
||||
extra_body: dict = None,
|
||||
debug: bool = False,
|
||||
) -> tuple[str, int, int, int]:
|
||||
"""
|
||||
Call the LLM and return (response_text, total_tokens, input_tokens, output_tokens).
|
||||
Retries up to DEFAULT_RETRIES times on timeout or connection errors.
|
||||
@@ -79,7 +89,7 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
try:
|
||||
import litellm
|
||||
except ImportError:
|
||||
raise RuntimeError("litellm is not installed. Add it to requirements.txt.")
|
||||
raise RuntimeError("litellm is not installed. Add it to requirements.txt.") from None
|
||||
|
||||
if debug:
|
||||
_install_litellm_debug()
|
||||
@@ -90,9 +100,12 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
'timeout': _timeout,
|
||||
'temperature': 0,
|
||||
'max_tokens': max_tokens if max_tokens is not None else _MAX_COMPLETION_TOKENS,
|
||||
}
|
||||
_m_lower = (model or '').lower()
|
||||
if not any(k in _m_lower for k in _NO_TEMPERATURE_MODEL_KEYWORDS):
|
||||
kwargs['temperature'] = 0
|
||||
|
||||
if api_key:
|
||||
kwargs['api_key'] = api_key
|
||||
if api_base:
|
||||
@@ -122,9 +135,9 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
attempt += 1
|
||||
try:
|
||||
response = litellm.completion(**kwargs)
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
finish = getattr(choice, 'finish_reason', None)
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
finish = getattr(choice, 'finish_reason', None)
|
||||
|
||||
text = message.content or ''
|
||||
|
||||
@@ -133,7 +146,9 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
parts = getattr(message, 'parts', None)
|
||||
if parts:
|
||||
text = ''.join(getattr(p, 'text', '') or '' for p in parts).strip()
|
||||
logger.debug(f"LLM client: extracted text from message.parts ({len(parts)} parts) model={model!r}")
|
||||
logger.debug(
|
||||
f"LLM client: extracted text from message.parts ({len(parts)} parts) model={model!r}"
|
||||
)
|
||||
|
||||
if finish == 'length':
|
||||
logger.warning(
|
||||
@@ -149,9 +164,13 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
)
|
||||
|
||||
usage = getattr(response, 'usage', None)
|
||||
input_tokens = int(getattr(usage, 'prompt_tokens', 0) or 0) if usage else 0
|
||||
input_tokens = int(getattr(usage, 'prompt_tokens', 0) or 0) if usage else 0
|
||||
output_tokens = int(getattr(usage, 'completion_tokens', 0) or 0) if usage else 0
|
||||
total_tokens = int(getattr(usage, 'total_tokens', 0) or 0) if usage else (input_tokens + output_tokens)
|
||||
total_tokens = (
|
||||
int(getattr(usage, 'total_tokens', 0) or 0)
|
||||
if usage
|
||||
else (input_tokens + output_tokens)
|
||||
)
|
||||
logger.debug(
|
||||
f"LLM client: model={model!r} finish={finish!r} "
|
||||
f"tokens={total_tokens} (in={input_tokens} out={output_tokens}) "
|
||||
@@ -181,21 +200,37 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
raise
|
||||
|
||||
except litellm.BadRequestError as e:
|
||||
# If the provider rejected an unsupported sampling param (and we haven't
|
||||
# already stripped them), drop them and retry once. attempt-=1 keeps this
|
||||
# off the timeout-retry budget; _stripped_sampling prevents a loop.
|
||||
msg = str(e).lower()
|
||||
if (not _stripped_sampling
|
||||
and any(p in kwargs for p in _sampling_params)
|
||||
and any(p in msg for p in _sampling_params)):
|
||||
# If the provider rejected an unsupported sampling param or extra_body
|
||||
# (e.g. Gemini INVALID_ARGUMENT on thinkingConfig or temperature), drop
|
||||
# them and retry once.
|
||||
if not _stripped_sampling:
|
||||
dropped = [p for p in _sampling_params if kwargs.pop(p, None) is not None]
|
||||
_stripped_sampling = True
|
||||
attempt -= 1
|
||||
logger.warning(
|
||||
f"LLM client: model={model!r} rejected sampling params {dropped} "
|
||||
f"({e}); retrying without them"
|
||||
)
|
||||
continue
|
||||
extra_body = kwargs.get('extra_body')
|
||||
if isinstance(extra_body, dict):
|
||||
gen_cfg = extra_body.get('generationConfig')
|
||||
if (
|
||||
isinstance(gen_cfg, dict)
|
||||
and gen_cfg.pop('thinkingConfig', None) is not None
|
||||
):
|
||||
dropped.append('thinkingConfig')
|
||||
if not gen_cfg:
|
||||
extra_body.pop('generationConfig', None)
|
||||
if not extra_body:
|
||||
kwargs.pop('extra_body', None)
|
||||
elif 'thinkingConfig' in extra_body:
|
||||
extra_body.pop('thinkingConfig', None)
|
||||
dropped.append('thinkingConfig')
|
||||
if not extra_body:
|
||||
kwargs.pop('extra_body', None)
|
||||
|
||||
if dropped:
|
||||
_stripped_sampling = True
|
||||
attempt -= 1
|
||||
logger.warning(
|
||||
f"LLM client: model={model!r} rejected request ({e}); "
|
||||
f"stripped {dropped} and retrying once"
|
||||
)
|
||||
continue
|
||||
logger.warning(f"LLM call failed: model={model!r} error={e}")
|
||||
raise
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ Two public entry points:
|
||||
- run_setup(watch, datastore) — one-time: decide if pre-filter needed
|
||||
- evaluate_change(watch, datastore, diff, current_snapshot) — per-change evaluation
|
||||
|
||||
Intent resolution: watch.llm_intent → first tag with llm_intent → None (no evaluation)
|
||||
Intent resolution: watch.llm_intent → first tag with llm_intent whose AI switch is "on"
|
||||
(see tag_llm_applies_to_watches) → None (no evaluation)
|
||||
Cache: each (intent, diff) pair is evaluated exactly once, result stored in watch.
|
||||
|
||||
Environment variable overrides (take priority over datastore settings):
|
||||
@@ -97,6 +98,8 @@ def _thinking_extra_body(model: str, budget: int) -> dict | None:
|
||||
"""
|
||||
if not model.startswith('gemini/'):
|
||||
return None
|
||||
if 'flash-lite' in model.lower():
|
||||
return None
|
||||
try:
|
||||
import litellm
|
||||
if not litellm.get_model_info(model).get('supports_reasoning'):
|
||||
@@ -244,6 +247,70 @@ def resolve_llm_timeout(llm_cfg: dict) -> int:
|
||||
# Intent resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A group/tag has exactly one AI control (`llm_backend_profile`), and it is ternary:
|
||||
#
|
||||
# True — AI on for every watch in the group, using the group's AI settings
|
||||
# (llm_intent / llm_change_summary cascade down to the watches)
|
||||
# False — AI off for every watch in the group; its prompts are stored but never used
|
||||
# None — the group has no opinion: each watch's own AI switch and prompts apply
|
||||
#
|
||||
# On a *watch* the same key is a plain bool (on/off, default on). One control per level, so
|
||||
# there is nothing to reconcile between an "override?" flag and an "enabled?" flag.
|
||||
def tag_llm_decision(tag):
|
||||
"""This group's AI decision: True (on, use its settings), False (off), or None (no opinion)."""
|
||||
if not tag:
|
||||
return None
|
||||
value = tag.get('llm_backend_profile')
|
||||
return None if value is None else bool(value)
|
||||
|
||||
|
||||
def tag_llm_applies_to_watches(tag) -> bool:
|
||||
"""True when this group hands its AI settings down to its watches.
|
||||
|
||||
Only the "on" state does that: a group set to "off" suppresses AI for its watches rather
|
||||
than lending them prompts, and a group with no opinion leaves them alone entirely. This is
|
||||
the single gate behind both the evaluator cascade and the watch edit page's
|
||||
"From group ..." placeholder.
|
||||
"""
|
||||
return tag_llm_decision(tag) is True
|
||||
|
||||
|
||||
def _watch_tags(watch, datastore):
|
||||
"""Yield this watch's tag dicts, in the watch's own tag order, skipping unknown UUIDs."""
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag:
|
||||
yield tag
|
||||
|
||||
|
||||
def _tags_applying_llm(watch, datastore):
|
||||
"""Yield this watch's groups, in order, that hand their AI settings to their watches."""
|
||||
for tag in _watch_tags(watch, datastore):
|
||||
if tag_llm_applies_to_watches(tag):
|
||||
yield tag
|
||||
|
||||
|
||||
def llm_enabled_for_watch(watch, datastore) -> tuple[bool, str]:
|
||||
"""Is automatic AI evaluation switched on for this watch? Returns (enabled, source).
|
||||
|
||||
See #4204 — users with hundreds of watches want AI on only a select few.
|
||||
|
||||
A group with an opinion decides for all of its watches ("the group setting overrides any
|
||||
watch on/off"), so the first such group wins over the watch's own switch; groups set to
|
||||
"leave it to each watch" are skipped. With no group deciding, the watch decides — and a
|
||||
missing key means on, so watches predating this switch keep working.
|
||||
|
||||
Only gates *automatic* spend (the worker's intent/summary passes and the restock AI
|
||||
plugin). Explicit user actions — the diff page "Summary" button, the intent preview —
|
||||
stay available, since those cost tokens only when someone deliberately clicks.
|
||||
"""
|
||||
for tag in _watch_tags(watch, datastore):
|
||||
decision = tag_llm_decision(tag)
|
||||
if decision is not None:
|
||||
return decision, tag.get('title', 'tag')
|
||||
return bool(watch.get('llm_backend_profile', True)), 'watch'
|
||||
|
||||
|
||||
def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
|
||||
"""
|
||||
Generic cascade resolver for any LLM per-watch field.
|
||||
@@ -254,12 +321,10 @@ def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
|
||||
if value:
|
||||
return value, 'watch'
|
||||
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag:
|
||||
tag_value = (tag.get(field) or '').strip()
|
||||
if tag_value:
|
||||
return tag_value, tag.get('title', 'tag')
|
||||
for tag in _tags_applying_llm(watch, datastore):
|
||||
tag_value = (tag.get(field) or '').strip()
|
||||
if tag_value:
|
||||
return tag_value, tag.get('title', 'tag')
|
||||
|
||||
return '', ''
|
||||
|
||||
@@ -273,12 +338,10 @@ def resolve_intent(watch, datastore) -> tuple[str, str]:
|
||||
if intent:
|
||||
return intent, 'watch'
|
||||
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag:
|
||||
tag_intent = (tag.get('llm_intent') or '').strip()
|
||||
if tag_intent:
|
||||
return tag_intent, tag.get('title', 'tag')
|
||||
for tag in _tags_applying_llm(watch, datastore):
|
||||
tag_intent = (tag.get('llm_intent') or '').strip()
|
||||
if tag_intent:
|
||||
return tag_intent, tag.get('title', 'tag')
|
||||
|
||||
return '', ''
|
||||
|
||||
@@ -549,15 +612,14 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
|
||||
def _first_tag_with_field(watch, datastore, field: str):
|
||||
"""Return (value, tag) for the first linked tag with a non-empty `field`, else ('', None).
|
||||
|
||||
Same first-match-wins order as resolve_llm_field(); this variant also hands back the
|
||||
tag itself so the caller can read sibling keys such as the prompt mode.
|
||||
Same first-match-wins order as resolve_llm_field() (so only groups opted in via
|
||||
tag_llm_applies_to_watches() count); this variant also hands back the tag itself so
|
||||
the caller can read sibling keys such as the prompt mode.
|
||||
"""
|
||||
for tag_uuid in watch.get('tags', []):
|
||||
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
|
||||
if tag:
|
||||
value = (tag.get(field) or '').strip()
|
||||
if value:
|
||||
return value, tag
|
||||
for tag in _tags_applying_llm(watch, datastore):
|
||||
value = (tag.get(field) or '').strip()
|
||||
if value:
|
||||
return value, tag
|
||||
return '', None
|
||||
|
||||
|
||||
|
||||
@@ -9,39 +9,44 @@ from .bm25_trim import trim_to_relevant
|
||||
|
||||
_AGO_RE = re.compile(r'^\d+\s+\w+\s+ago$', re.IGNORECASE)
|
||||
|
||||
SNAPSHOT_CONTEXT_CHARS = 30_000 # current page state excerpt sent alongside the diff
|
||||
SNAPSHOT_CONTEXT_CHARS = 30_000 # current page state excerpt sent alongside the diff
|
||||
|
||||
|
||||
def _annotate_moved_lines(diff_text: str) -> str:
|
||||
"""
|
||||
Pre-process a unified diff to mark lines that appear on both the + and - sides
|
||||
as [MOVED] rather than genuinely added/removed. This prevents the LLM from
|
||||
as [MOVED] (~ prefix) rather than genuinely added/removed. This prevents the LLM from
|
||||
incorrectly classifying repositioned content as new or deleted.
|
||||
Also marks standalone relative timestamps (e.g. '3 hours ago') as ~ trivial.
|
||||
|
||||
Lines are compared after stripping leading +/- and whitespace so that
|
||||
indentation changes don't prevent matching.
|
||||
"""
|
||||
lines = diff_text.splitlines()
|
||||
added_texts = {l[1:].strip().lower() for l in lines if l.startswith('+') and l[1:].strip()}
|
||||
removed_texts = {l[1:].strip().lower() for l in lines if l.startswith('-') and l[1:].strip()}
|
||||
moved_texts = added_texts & removed_texts
|
||||
|
||||
if not moved_texts:
|
||||
return diff_text
|
||||
added_texts = {
|
||||
line[1:].strip().lower() for line in lines if line.startswith('+') and line[1:].strip()
|
||||
}
|
||||
removed_texts = {
|
||||
line[1:].strip().lower() for line in lines if line.startswith('-') and line[1:].strip()
|
||||
}
|
||||
moved_texts = added_texts & removed_texts
|
||||
|
||||
result = []
|
||||
has_changes = False
|
||||
for line in lines:
|
||||
if line.startswith(('+', '-')):
|
||||
bare = line[1:].strip().lower()
|
||||
if bare in moved_texts or _AGO_RE.match(line[1:].strip()):
|
||||
result.append(f'~{line[1:]}') # ~ prefix = moved/reordered/trivial, skip
|
||||
has_changes = True
|
||||
continue
|
||||
result.append(line)
|
||||
return '\n'.join(result)
|
||||
return '\n'.join(result) if has_changes else diff_text
|
||||
|
||||
|
||||
def build_eval_prompt(intent: str, diff: str, current_snapshot: str = '',
|
||||
url: str = '', title: str = '') -> str:
|
||||
def build_eval_prompt(
|
||||
intent: str, diff: str, current_snapshot: str = '', url: str = '', title: str = ''
|
||||
) -> str:
|
||||
"""
|
||||
Build the user message for a diff evaluation call.
|
||||
The system prompt is kept separate (see build_eval_system_prompt).
|
||||
@@ -131,8 +136,9 @@ def build_preview_system_prompt() -> str:
|
||||
)
|
||||
|
||||
|
||||
def build_change_summary_prompt(diff: str, custom_prompt: str,
|
||||
current_snapshot: str = '', url: str = '', title: str = '') -> str:
|
||||
def build_change_summary_prompt(
|
||||
diff: str, custom_prompt: str, current_snapshot: str = '', url: str = '', title: str = ''
|
||||
) -> str:
|
||||
"""
|
||||
Build the user message for an AI Change Summary call.
|
||||
The user supplies their own instructions (custom_prompt); this wraps them
|
||||
|
||||
@@ -9,16 +9,56 @@ text. This module handles those cases gracefully.
|
||||
import json
|
||||
import re
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
|
||||
# Positional selectors are fragile — reject them even if the LLM generates them
|
||||
_POSITIONAL_SELECTOR_RE = re.compile(
|
||||
r'nth-child|nth-of-type|:eq\(|\[\d+\]|\/\/\*\[\d',
|
||||
re.IGNORECASE
|
||||
r'nth-child|nth-of-type|:eq\(|\[\d+\]|\/\/\*\[\d', re.IGNORECASE
|
||||
)
|
||||
|
||||
# Reasoning models (DeepSeek-R1, Qwen reasoning, etc.) wrap their scratchpad in <think> tags.
|
||||
# Three shapes have to be handled, because the scratchpad routinely contains JSON of its own
|
||||
# ("initially I thought {"important": false}, but..."), so leaving any of it in place lets
|
||||
# _extract_json lock onto a discarded intermediate answer instead of the real one.
|
||||
_THINK_BLOCK_RE = re.compile(r'<think(?:ing)?>.*?</think(?:ing)?>', re.DOTALL | re.IGNORECASE)
|
||||
_THINK_TAIL_RE = re.compile(r'^.*</think(?:ing)?>', re.DOTALL | re.IGNORECASE)
|
||||
_THINK_OPEN_RE = re.compile(r'<think(?:ing)?>', re.IGNORECASE)
|
||||
|
||||
|
||||
def _to_bool(value, default: bool = False) -> bool:
|
||||
"""Safely coerce boolean values from LLM responses.
|
||||
|
||||
Handles native booleans, truthy/falsy integers (1/0), and string booleans
|
||||
("true", "false", "yes", "no", "1", "0") using strtobool.
|
||||
Avoids Python's bool("false") -> True bug on stringified JSON booleans.
|
||||
"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return strtobool(value)
|
||||
except (ValueError, AttributeError):
|
||||
return default
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> str:
|
||||
"""Strip markdown fences and extract the first JSON object."""
|
||||
"""Strip reasoning blocks, markdown fences, and extract the first JSON object.
|
||||
|
||||
Raises:
|
||||
ValueError: the response opens a reasoning block it never closes, i.e. it was cut
|
||||
off mid-thought (usually by max_tokens) and contains no answer at all. Callers
|
||||
in evaluator.py catch this and fall back safely - for diff evaluation that
|
||||
means passing the change through as important rather than silently dropping it.
|
||||
"""
|
||||
raw = raw.strip()
|
||||
# Well-formed scratchpads.
|
||||
raw = _THINK_BLOCK_RE.sub('', raw).strip()
|
||||
# Some providers/chat templates emit the opening tag themselves and only the closer comes
|
||||
# back over the wire, so anything up to the last closer is still scratchpad.
|
||||
raw = _THINK_TAIL_RE.sub('', raw).strip()
|
||||
# An opener with no closer means the response was truncated part-way through reasoning.
|
||||
# There is no answer to find; the only JSON present would be a discarded intermediate one.
|
||||
if _THINK_OPEN_RE.search(raw):
|
||||
raise ValueError('LLM response contains an unterminated reasoning block (truncated?)')
|
||||
# Remove ```json ... ``` or ``` ... ``` fences
|
||||
raw = re.sub(r'^```(?:json)?\s*', '', raw, flags=re.MULTILINE)
|
||||
raw = re.sub(r'\s*```$', '', raw, flags=re.MULTILINE)
|
||||
@@ -36,7 +76,7 @@ def parse_eval_response(raw: str) -> dict:
|
||||
try:
|
||||
data = json.loads(_extract_json(raw))
|
||||
return {
|
||||
'important': bool(data.get('important', False)),
|
||||
'important': _to_bool(data.get('important'), default=False),
|
||||
'summary': str(data.get('summary', '')).strip(),
|
||||
}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
@@ -52,7 +92,7 @@ def parse_preview_response(raw: str) -> dict:
|
||||
try:
|
||||
data = json.loads(_extract_json(raw))
|
||||
return {
|
||||
'found': bool(data.get('found', False)),
|
||||
'found': _to_bool(data.get('found'), default=False),
|
||||
'answer': str(data.get('answer', '')).strip(),
|
||||
}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
@@ -67,7 +107,7 @@ def parse_setup_response(raw: str) -> dict:
|
||||
"""
|
||||
try:
|
||||
data = json.loads(_extract_json(raw))
|
||||
needs = bool(data.get('needs_prefilter', False))
|
||||
needs = _to_bool(data.get('needs_prefilter'), default=False)
|
||||
selector = data.get('selector') or None
|
||||
|
||||
# Sanitise: reject positional selectors
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from os import getenv
|
||||
from copy import deepcopy
|
||||
|
||||
from changedetectionio.blueprint.menu_modes import MENU_SIDEBAR_ACTIONMODES_DEFAULT
|
||||
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTENT_FORMAT_DEFAULT
|
||||
from changedetectionio.model.Tags import TagsDict
|
||||
|
||||
@@ -80,7 +81,7 @@ class model(dict):
|
||||
'socket_io_enabled': True,
|
||||
'favicons_enabled': True,
|
||||
'timeago_format': 'long', # 'long' = "1 minute ago", 'short' = "1m ago"
|
||||
'sidebar_mode': 'collapsed', # 'collapsed' = slim icon rail, expands on hover; 'pinned' = always expanded
|
||||
'sidebar_mode': MENU_SIDEBAR_ACTIONMODES_DEFAULT, # one of blueprint.menu_modes.MENU_SIDEBAR_ACTIONMODES
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
super(model, self).__init__(*arg, **kw)
|
||||
|
||||
self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')
|
||||
# Ternary on a tag, unlike the plain bool on a watch: None ("leave it to each watch")
|
||||
# is the default, so a group never touches its watches' AI until explicitly set.
|
||||
# See llm/evaluator.py:tag_llm_decision().
|
||||
self['llm_backend_profile'] = kw.get('default', {}).get('llm_backend_profile', None)
|
||||
self['url_match_pattern'] = kw.get('default', {}).get('url_match_pattern', '')
|
||||
|
||||
if kw.get('default'):
|
||||
|
||||
@@ -184,16 +184,11 @@ class watch_base(dict):
|
||||
'check_count': 0,
|
||||
'check_unique_lines': False, # On change-detected, compare against all history if its something new
|
||||
'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine.
|
||||
# LLM intent-based evaluation
|
||||
'content-type': None,
|
||||
'date_created': None,
|
||||
'extract_lines_containing': [], # Keep only lines containing these substrings (plain text, case-insensitive)
|
||||
'extract_text': [], # Extract text by regex after filters
|
||||
# LLM intent-based evaluation
|
||||
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
|
||||
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
|
||||
'llm_change_summary_mode': 'replace', # 'replace' the inherited prompt, or 'append' to it
|
||||
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
|
||||
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
|
||||
'fetch_backend': 'system', # plaintext, playwright etc
|
||||
'fetch_time': 0.0,
|
||||
'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),
|
||||
@@ -202,16 +197,22 @@ class watch_base(dict):
|
||||
'filter_text_replaced': True,
|
||||
'follow_price_changes': True,
|
||||
'has_ldjson_price_data': None,
|
||||
'history_snapshot_max_length': None,
|
||||
'headers': {}, # Extra headers to send
|
||||
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
|
||||
'history_snapshot_max_length': None,
|
||||
'ignore_status_codes': None,
|
||||
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
|
||||
'in_stock_only': True, # Only trigger change on going to instock from out-of-stock
|
||||
'include_filters': [],
|
||||
'last_checked': 0,
|
||||
'last_error': False,
|
||||
'last_notification_error': None,
|
||||
'last_viewed': 0, # history key value of the last viewed via the [diff] link
|
||||
'llm_backend_profile': True, # @note - now its just a bool but in the near future we can select a LLM profile or 'off'/false
|
||||
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
|
||||
'llm_change_summary_mode': 'replace', # 'replace' the inherited prompt, or 'append' to it
|
||||
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
|
||||
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
|
||||
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
|
||||
'method': 'GET',
|
||||
'notification_alert_count': 0,
|
||||
'notification_body': None,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
|
||||
import time
|
||||
import re
|
||||
import apprise
|
||||
from apprise import NotifyFormat
|
||||
from loguru import logger
|
||||
from urllib.parse import urlparse
|
||||
from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL
|
||||
from .email_helpers import as_monospaced_html_email
|
||||
from ..diff import HTML_REMOVED_STYLE, REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED, ADDED_PLACEMARKER_OPEN, HTML_ADDED_STYLE, \
|
||||
ADDED_PLACEMARKER_CLOSED, CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED, CHANGED_PLACEMARKER_OPEN, \
|
||||
@@ -64,6 +61,7 @@ def notification_format_align_with_apprise(n_format : str):
|
||||
:param n_format:
|
||||
:return:
|
||||
"""
|
||||
from apprise import NotifyFormat
|
||||
|
||||
if not n_format:
|
||||
return NotifyFormat.TEXT.value
|
||||
@@ -208,6 +206,7 @@ def replace_placemarkers_in_text(text, url, requested_output_format):
|
||||
|
||||
def apply_service_tweaks(url, n_body, n_title, requested_output_format):
|
||||
|
||||
from .apprise_plugin.assets import APPRISE_AVATAR_URL
|
||||
logger.debug(f"Applying markup in '{requested_output_format}' mode")
|
||||
|
||||
# Re 323 - Limit discord length to their 2000 char limit total or it wont send.
|
||||
@@ -305,6 +304,9 @@ def apply_service_tweaks(url, n_body, n_title, requested_output_format):
|
||||
|
||||
|
||||
def process_notification(n_object: NotificationContextData, datastore):
|
||||
import apprise
|
||||
from apprise import NotifyFormat
|
||||
from .apprise_plugin.assets import apprise_asset
|
||||
from changedetectionio.jinja2_custom import render as jinja_render
|
||||
from . import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH, default_notification_format, valid_notification_formats
|
||||
# be sure its registered
|
||||
|
||||
@@ -506,8 +506,11 @@ class perform_site_check(difference_detection_processor):
|
||||
# Try plugin override - plugins can decide if they support this fetcher
|
||||
if fetcher_name:
|
||||
logger.debug(f"Calling extra plugins for getting item price/availability (fetcher: {fetcher_name})")
|
||||
from changedetectionio.llm.evaluator import resolve_intent
|
||||
_llm_intent, _ = resolve_intent(watch, self.datastore)
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch, resolve_intent
|
||||
# AI off for this watch (or for its group) means no intent is handed to the
|
||||
# LLM restock plugin, so it doesn't spend tokens here either — #4204.
|
||||
_llm_on, _ = llm_enabled_for_watch(watch, self.datastore)
|
||||
_llm_intent, _ = resolve_intent(watch, self.datastore) if _llm_on else ('', '')
|
||||
plugin_availability = get_itemprop_availability_from_plugin(self.fetcher.content, fetcher_name, self.fetcher, watch.link, llm_intent=_llm_intent or None)
|
||||
|
||||
if plugin_availability:
|
||||
|
||||
@@ -78,7 +78,9 @@ $(document).ready(function () {
|
||||
// bootstrap it, this will trigger everything else
|
||||
$('#browsersteps-img').bind('load', function () {
|
||||
$('body').addClass('full-width');
|
||||
console.log("Loaded background...");
|
||||
console.log(`Loaded background ${this.naturalWidth}px` );
|
||||
// For the UI width of the whole edit area
|
||||
document.documentElement.style.setProperty('--browser-steps-max-width', `${this.naturalWidth+200}px` );
|
||||
|
||||
document.getElementById("browsersteps-selector-canvas");
|
||||
c = document.getElementById("browsersteps-selector-canvas");
|
||||
@@ -284,7 +286,7 @@ $(document).ready(function () {
|
||||
$('#browser-steps-ui .loader .spinner').show();
|
||||
// Request a new session
|
||||
$.ajax({
|
||||
type: "GET",
|
||||
type: "POST",
|
||||
url: browser_steps_start_url,
|
||||
statusCode: {
|
||||
400: function () {
|
||||
|
||||
@@ -179,6 +179,25 @@ function toggleOpacity(checkboxSelector, fieldSelector, inverted) {
|
||||
checkbox.addEventListener('change', updateOpacity);
|
||||
}
|
||||
|
||||
// Radio-group counterpart of toggleOpacity: fields are full opacity only while the named
|
||||
// radio group sits on activeValue, otherwise greyed out. Used by the tag AI/LLM tab, where a
|
||||
// ternary (On / Off / Leave it to each watch) decides whether the prompts below apply.
|
||||
function toggleOpacityByRadioValue(radioName, activeValue, fieldSelector) {
|
||||
const radios = document.querySelectorAll(`input[type="radio"][name="${radioName}"]`);
|
||||
const fields = document.querySelectorAll(fieldSelector);
|
||||
|
||||
function updateOpacity() {
|
||||
const active = Array.from(radios).some(radio => radio.checked && radio.value === activeValue);
|
||||
fields.forEach(field => {
|
||||
field.style.opacity = active ? 1 : 0.6;
|
||||
});
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
updateOpacity();
|
||||
radios.forEach(radio => radio.addEventListener('change', updateOpacity));
|
||||
}
|
||||
|
||||
function toggleVisibility(checkboxSelector, fieldSelector, inverted) {
|
||||
const checkbox = document.querySelector(checkboxSelector);
|
||||
const fields = document.querySelectorAll(fieldSelector);
|
||||
|
||||
@@ -73,7 +73,7 @@ $(function () {
|
||||
|
||||
// Request start, needs CSRF?
|
||||
$.ajax({
|
||||
type: "GET",
|
||||
type: "POST",
|
||||
url: recheck_proxy_start_url,
|
||||
}).done(function (data) {
|
||||
$.each(data, function (proxy_key, state) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// Left-rail expand/collapse state.
|
||||
// Adds `action-side-bar-expanded` to <body> whenever the rail is showing its
|
||||
// labels. In pinned mode (body.actionside-bar-on) the class is already present
|
||||
// from page load; in collapsed mode (body.actionsidebar-minimal) the rail only
|
||||
// expands on hover/focus, so we toggle the class to match.
|
||||
// labels. In 'pinned-expanded' mode (body.actionside-bar-on) the class is already
|
||||
// present from page load; in 'expandable' mode (body.actionsidebar-minimal) the rail
|
||||
// only expands on hover/focus, so we toggle the class to match. 'minimal' mode is
|
||||
// also the collapsed rail but carries `actionsidebar-no-expand` and never rolls out.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!document.body.classList.contains('actionsidebar-minimal')) {
|
||||
if (!document.body.classList.contains('actionsidebar-minimal') ||
|
||||
document.body.classList.contains('actionsidebar-no-expand')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,9 @@ window.initVisualSelector = function (opts) {
|
||||
$('#selector-current-xpath, #clear-selector').hide();
|
||||
})
|
||||
.on('load', () => {
|
||||
console.log("Loaded background...");
|
||||
console.log(`Loaded background ${$selectorBackgroundElem[0].naturalWidth}px`);
|
||||
// For the UI width of the whole edit area
|
||||
document.documentElement.style.setProperty('--visualselector-max-width', `${$selectorBackgroundElem[0].naturalWidth}px` );
|
||||
c = $selectorCanvasElem[0];
|
||||
xctx = c.getContext("2d");
|
||||
ctx = c.getContext("2d");
|
||||
|
||||
@@ -32,21 +32,27 @@ $action-sidebar-content-slot: 1100px;
|
||||
// Hidden on mobile: the mobile drawer (hamburger) carries these items
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
body.actionside-bar-on {
|
||||
.action-sidebar {
|
||||
/* width: $action-sidebar-width-expanded; // reserve enough to host the expanded inner block*/
|
||||
// MINIMAL MODE: the rail's footprint in the flex row is frozen at the
|
||||
// collapsed width (+ the inner block's horizontal padding, which is
|
||||
// content-box). The inner block is taken out of flow (absolute, below) so
|
||||
// its hover expansion rolls out ON TOP of the page content instead of
|
||||
// widening this flex item and reflowing `.app-main`.
|
||||
body.actionsidebar-minimal & {
|
||||
flex: 0 0 auto;
|
||||
width: calc(#{$action-sidebar-width-collapsed} + #{$common-gap * 2});
|
||||
overflow: visible; // let the expanded inner block escape the frame
|
||||
}
|
||||
}
|
||||
|
||||
// The actual interactive item block — wraps only the items, not the whole height.
|
||||
//
|
||||
// Two body-level modes drive the rail's display state:
|
||||
// body.actionside-bar-on → always expanded (icons + labels visible all the time)
|
||||
// body.actionsidebar-minimal → icon-only collapsed rail; expands on hover/focus
|
||||
// `actionside-bar-on` is the default applied in templates/base.html. Both the
|
||||
// hover-to-expand width animation and the label fade-in are gated on the
|
||||
// Body-level classes drive the rail's display state (emitted by
|
||||
// flask_app.get_sidebar_mode_class() from the `sidebar_mode` setting):
|
||||
// body.actionside-bar-on → always expanded (icons + labels visible all the time)
|
||||
// body.actionsidebar-minimal → icon-only collapsed rail; expands on hover/focus
|
||||
// body.actionsidebar-no-expand → alongside the above: stays collapsed, no hover-out
|
||||
// Both the hover-to-expand width animation and the label fade-in are gated on the
|
||||
// minimal class so that the always-expanded mode (or no mode) doesn't trigger
|
||||
// a needless layout jump when the user mouses across the rail.
|
||||
.action-sidebar-inner {
|
||||
@@ -64,12 +70,40 @@ body.actionside-bar-on {
|
||||
padding-left: $common-gap;
|
||||
padding-right: $common-gap;
|
||||
|
||||
// Hover-to-expand only fires in minimal mode.
|
||||
body.actionsidebar-minimal &:hover,
|
||||
body.actionsidebar-minimal &:focus-within {
|
||||
// MINIMAL MODE: lift the block out of the flex flow so growing it can't
|
||||
// resize the rail (and therefore can't shift the content column). The rail
|
||||
// is `position: sticky`, i.e. a positioned element, so top/left/bottom here
|
||||
// anchor to it; top+bottom keep the column full-height, which the footer
|
||||
// list's `margin-top: auto` still needs.
|
||||
//
|
||||
// Once it floats above the page it needs its own backdrop, or the content
|
||||
// it rolls over would read straight through the rail's translucent 5% white.
|
||||
// It can't just be a flat colour: the page background is a fixed, viewport-
|
||||
// sized gradient (`body:after`), so the rail repaints that same gradient with
|
||||
// `background-attachment: fixed` — which makes the viewport the positioning
|
||||
// area — and lays the rail's own 5% white on top. Result: opaque, but
|
||||
// pixel-identical to the translucent rail it replaces, at rest and rolled out.
|
||||
body.actionsidebar-minimal & {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--color-background-page); // fallback under the gradient
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05)),
|
||||
var(--page-background-gradient);
|
||||
background-attachment: fixed, fixed;
|
||||
}
|
||||
|
||||
// Hover-to-expand only fires in minimal mode, and not for 'minimal' (no-expand).
|
||||
body.actionsidebar-minimal:not(.actionsidebar-no-expand) &:hover,
|
||||
body.actionsidebar-minimal:not(.actionsidebar-no-expand) &:focus-within {
|
||||
width: $action-sidebar-width-expanded;
|
||||
// Enter is smoother.
|
||||
transition: width 0.22s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
// Lift it off the page while it's rolled out over the content.
|
||||
box-shadow: var(--color-sidebar-shadow);
|
||||
}
|
||||
|
||||
// Always-expanded mode: no hover gate; the rail just sits open.
|
||||
@@ -250,8 +284,8 @@ ul.action-sidebar-list {
|
||||
// When the inner block expands in MINIMAL mode, reveal every label/badge
|
||||
// together with a smooth enter. Always-expanded mode handles labels via the
|
||||
// `body.actionside-bar-on` block below — no hover gate needed there.
|
||||
body.actionsidebar-minimal .action-sidebar-inner:hover,
|
||||
body.actionsidebar-minimal .action-sidebar-inner:focus-within {
|
||||
body.actionsidebar-minimal:not(.actionsidebar-no-expand) .action-sidebar-inner:hover,
|
||||
body.actionsidebar-minimal:not(.actionsidebar-no-expand) .action-sidebar-inner:focus-within {
|
||||
.action-sidebar-item {
|
||||
.action-label {
|
||||
opacity: 1;
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
position: relative;
|
||||
height: 80vh;
|
||||
|
||||
> img {
|
||||
position: absolute;
|
||||
|
||||
@@ -91,3 +91,26 @@
|
||||
&:hover { color: #d68a00; border-color: #d68a00; }
|
||||
}
|
||||
}
|
||||
|
||||
// State-mutating controls have to POST, and only a <button> can submit - these strip
|
||||
// the browser's button chrome so they render exactly like the <a> they replaced.
|
||||
.bare-btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
// Matches `a { color: var(--color-link) }` - these replace anchors, and the row
|
||||
// icons stroke with currentColor, so `inherit` would pick up .watch-controls red.
|
||||
color: var(--color-link);
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-link);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
li {
|
||||
border-bottom: 1px solid var(--color-border-table-cell);
|
||||
|
||||
>* {
|
||||
>*, >form>button {
|
||||
display: block;
|
||||
padding: 1rem 1.5rem;
|
||||
color: var(--color-text);
|
||||
@@ -134,6 +134,14 @@
|
||||
background: var(--color-background-menu-link-hover);
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons shrink-wrap and centre their label; anchors don't. No global
|
||||
// border-box reset here, so this must not be folded into the rule above.
|
||||
>form>button {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
&#menu-pause, &#menu-mute {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,20 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
// The csrf mini-form around each option is layout-transparent, so the buttons
|
||||
// stay the flex items.
|
||||
> form {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
.language-option {
|
||||
display: flex;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem;
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
|
||||
.pure-menu-item {
|
||||
height: initial;
|
||||
|
||||
// Mini POST forms (pause/mute/log out need a csrf_token) are layout-transparent,
|
||||
// so the button inside sits where the plain <a> used to.
|
||||
> form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ ul#top-right-menu {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
--color-background-gradient-first: #5ad8f7;
|
||||
--color-background-gradient-second: #2f50af;
|
||||
--color-background-gradient-third: #9150bf;
|
||||
// The page's atmosphere gradient, shared so anything that needs to sit ON the
|
||||
// page background (e.g. the left rail rolling out over content) can repaint
|
||||
// an identical copy instead of approximating it with a flat colour. The
|
||||
// referenced gradient stops are re-declared per theme below, so this single
|
||||
// definition follows light/dark automatically.
|
||||
--page-background-gradient: linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%);
|
||||
--color-background: var(--color-white);
|
||||
--color-text: var(--color-grey-200);
|
||||
--color-link: #1b98f8;
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
body:has(#browser-steps:target) .edit-form {
|
||||
max-width: min(var(--browser-steps-max-width, 1280px), 95vw);
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
body:has(#visualselector:target) .edit-form {
|
||||
width: 95%;
|
||||
max-width: min(var(--visualselector-max-width, 1280px), 95vw);
|
||||
}
|
||||
|
||||
#selector-wrapper {
|
||||
height: 100%;
|
||||
|
||||
@@ -205,7 +205,7 @@ body.watch-selection-active #checkbox-operations {
|
||||
}
|
||||
|
||||
&.queued {
|
||||
a.recheck {
|
||||
.recheck {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ body.watch-selection-active #checkbox-operations {
|
||||
}
|
||||
|
||||
&.paused {
|
||||
a.pause-toggle {
|
||||
.pause-toggle {
|
||||
&.state-on {
|
||||
display: inline !important;
|
||||
}
|
||||
@@ -228,7 +228,7 @@ body.watch-selection-active #checkbox-operations {
|
||||
}
|
||||
|
||||
&.notification_muted {
|
||||
a.mute-toggle {
|
||||
.mute-toggle {
|
||||
&.state-on {
|
||||
display: inline !important;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
@use "parts/sub_tabs";
|
||||
|
||||
// Smooth transitions for theme switching
|
||||
body,
|
||||
// Disabled - people complained
|
||||
/*body,
|
||||
.pure-table,
|
||||
.pure-table thead,
|
||||
.pure-table td,
|
||||
@@ -65,7 +66,7 @@ code,
|
||||
a,
|
||||
.watch-controls img {
|
||||
transition: color 0.4s ease, background-color 0.4s ease, background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
}*/
|
||||
|
||||
body {
|
||||
color: var(--color-text);
|
||||
@@ -305,7 +306,7 @@ code {
|
||||
|
||||
body:after {
|
||||
content: "";
|
||||
background: linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%);
|
||||
background: var(--page-background-gradient);
|
||||
}
|
||||
|
||||
body:after,
|
||||
@@ -697,12 +698,12 @@ footer {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 760px),
|
||||
(min-device-width: 768px) and (max-device-width: $desktop-wide-breakpoint) {
|
||||
.edit-form {
|
||||
padding: 0.5em;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#nav-menu {
|
||||
@@ -1152,4 +1153,8 @@ header {
|
||||
cursor: pointer;
|
||||
width: 1.4rem; /*it's slightly more wider than square so default auto will trim it slightly */
|
||||
}
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,8 @@ from changedetectionio.strtobool import strtobool
|
||||
from changedetectionio.validate_url import is_safe_valid_url
|
||||
|
||||
from flask import (
|
||||
flash
|
||||
flash,
|
||||
has_request_context
|
||||
)
|
||||
from flask_babel import gettext
|
||||
|
||||
@@ -48,6 +49,9 @@ dictfilt = lambda x, y: dict([(i, x[i]) for i in x if i in set(y)])
|
||||
# Is there an existing library to ensure some data store (JSON etc) is in sync with CRUD methods?
|
||||
# Open a github issue if you know something :)
|
||||
# https://stackoverflow.com/questions/6190468/how-to-trigger-function-on-value-change
|
||||
_TAG_UUID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
|
||||
|
||||
|
||||
class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
__version_check = True
|
||||
|
||||
@@ -661,9 +665,8 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
# NOTE: dict() is shallow copy but safe since add_watch() deepcopies it
|
||||
with self.lock:
|
||||
extras = dict(self.data['watching'][uuid])
|
||||
new_uuid = self.add_watch(url=url, extras=extras)
|
||||
watch = self.data['watching'][new_uuid]
|
||||
return new_uuid
|
||||
# None when add_watch() refused it (e.g. PAGE_WATCH_LIMIT), having already flashed why
|
||||
return self.add_watch(url=url, extras=extras)
|
||||
|
||||
def url_exists(self, url):
|
||||
|
||||
@@ -679,6 +682,38 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
self.__data['watching'][uuid].clear_watch()
|
||||
self.__data['watching'][uuid].commit()
|
||||
|
||||
@property
|
||||
def watch_limit(self):
|
||||
"""Total watches allowed by PAGE_WATCH_LIMIT, or None when there is no limit.
|
||||
|
||||
None means "unlimited" and is the normal case - the env var being absent, empty or
|
||||
unparseable all leave the limit switched off entirely. There is no default.
|
||||
"""
|
||||
limit = os.getenv('PAGE_WATCH_LIMIT')
|
||||
if not limit:
|
||||
return None
|
||||
try:
|
||||
return int(limit)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid PAGE_WATCH_LIMIT value: {limit}, ignoring limit check")
|
||||
return None
|
||||
|
||||
def watch_limit_reached(self):
|
||||
"""True when the limit is set and leaves no room for another watch.
|
||||
|
||||
add_watch() enforces this on its own, but it can only return None. Callers that can
|
||||
report something better - a 429 in the API, one flash instead of one per row in the
|
||||
importers - should check this first.
|
||||
"""
|
||||
limit = self.watch_limit
|
||||
return limit is not None and len(self.__data['watching']) >= limit
|
||||
|
||||
def watch_limit_message(self):
|
||||
"""The single wording for a blocked add, so every UI surface says the same thing."""
|
||||
return gettext("Watch limit reached ({current}/{limit} watches). Cannot add more watches.").format(
|
||||
current=len(self.__data['watching']), limit=self.watch_limit
|
||||
)
|
||||
|
||||
def add_watch(self, url, tag='', extras=None, tag_uuids=None, save_immediately=True, seed_data_dir=None):
|
||||
"""
|
||||
seed_data_dir: optional path to an existing directory (already in the watch's
|
||||
@@ -742,32 +777,48 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
return False
|
||||
|
||||
if not is_safe_valid_url(url):
|
||||
from flask import has_request_context
|
||||
if has_request_context():
|
||||
flash(gettext('Watch protocol is not permitted or invalid URL format'), 'error')
|
||||
else:
|
||||
logger.error(f"add_watch: URL '{url}' is not permitted or invalid, skipping.")
|
||||
return None
|
||||
|
||||
# Check PAGE_WATCH_LIMIT if set
|
||||
page_watch_limit = os.getenv('PAGE_WATCH_LIMIT')
|
||||
if page_watch_limit:
|
||||
try:
|
||||
page_watch_limit = int(page_watch_limit)
|
||||
current_watch_count = len(self.__data['watching'])
|
||||
if current_watch_count >= page_watch_limit:
|
||||
logger.error(f"Watch limit reached: {current_watch_count}/{page_watch_limit} watches. Cannot add {url}")
|
||||
flash(gettext("Watch limit reached ({current}/{limit} watches). Cannot add more watches.").format(current=current_watch_count, limit=page_watch_limit), 'error')
|
||||
return None
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid PAGE_WATCH_LIMIT value: {page_watch_limit}, ignoring limit check")
|
||||
# Backstop for PAGE_WATCH_LIMIT - every add path funnels through here, so nothing can
|
||||
# get past the limit even if a caller forgets to pre-check watch_limit_reached().
|
||||
if self.watch_limit_reached():
|
||||
logger.error(f"Watch limit reached: {len(self.__data['watching'])}/{self.watch_limit} watches. Cannot add {url}")
|
||||
# The CLI (-u) and the API's background import thread have no request context,
|
||||
# where flash() raises instead of reporting anything
|
||||
if has_request_context():
|
||||
flash(self.watch_limit_message(), 'error')
|
||||
return None
|
||||
|
||||
if tag and type(tag) == str:
|
||||
# Then it's probably a string of the actual tag by name, split and add it
|
||||
for t in tag.split(','):
|
||||
# for each stripped tag, add tag as UUID
|
||||
for a_t in t.split(','):
|
||||
tag_uuid = self.add_tag(a_t)
|
||||
# A comma separated string of tag *titles*, created when they don't exist yet.
|
||||
# An existing tag's UUID is accepted here too: the API documented this field as taking
|
||||
# a UUID for years, and honouring that beats creating a tag *titled* with the UUID.
|
||||
existing_tag_uuids = self.__data['settings']['application'].get('tags', {})
|
||||
|
||||
for tag_name in tag.split(','):
|
||||
tag_name = tag_name.strip()
|
||||
if not tag_name:
|
||||
continue
|
||||
|
||||
if _TAG_UUID_RE.match(tag_name):
|
||||
if tag_name in existing_tag_uuids:
|
||||
apply_extras['tags'].append(tag_name)
|
||||
continue
|
||||
# UUID-shaped but no such tag, and no tag literally titled that either -
|
||||
# a stale or foreign ID. Skip it rather than leave behind a group named
|
||||
# after a UUID, which is never what the caller wanted.
|
||||
if not self.tag_uuid_for_title(tag_name):
|
||||
logger.warning(f"Tag '{tag_name}' looks like a UUID but no such tag exists, skipping")
|
||||
continue
|
||||
|
||||
tag_uuid = self.add_tag(tag_name)
|
||||
# add_tag() returns False for a title it won't create - never let that into the list,
|
||||
# a falsy entry blows up every lookup of watch['tags']
|
||||
if tag_uuid:
|
||||
apply_extras['tags'].append(tag_uuid)
|
||||
|
||||
# Or if UUIDs given directly
|
||||
@@ -1041,6 +1092,18 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
|
||||
return ret
|
||||
|
||||
def tag_uuid_for_title(self, title):
|
||||
"""UUID of the tag with this title (case/space insensitive), or None. Creates nothing."""
|
||||
n = title.strip().lower()
|
||||
if not n:
|
||||
return None
|
||||
|
||||
for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():
|
||||
if n == tag.get('title', '').lower().strip():
|
||||
return uuid
|
||||
|
||||
return None
|
||||
|
||||
def add_tag(self, title):
|
||||
# If name exists, return that
|
||||
n = title.strip().lower()
|
||||
@@ -1048,10 +1111,10 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
if not n:
|
||||
return False
|
||||
|
||||
for uuid, tag in self.__data['settings']['application'].get('tags', {}).items():
|
||||
if n == tag.get('title', '').lower().strip():
|
||||
logger.warning(f"Tag '{title}' already exists, skipping creation.")
|
||||
return uuid
|
||||
existing_uuid = self.tag_uuid_for_title(title)
|
||||
if existing_uuid:
|
||||
logger.warning(f"Tag '{title}' already exists, skipping creation.")
|
||||
return existing_uuid
|
||||
|
||||
# Eventually almost everything todo with a watch will apply as a Tag
|
||||
# So we use the same model as a Watch
|
||||
|
||||
@@ -7,7 +7,6 @@ This module provides the FileSavingDataStore abstract class that implements:
|
||||
- Atomic file writes safe for NFS/NAS
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
@@ -208,7 +207,46 @@ def save_watch_atomic(watch_dir, uuid, watch_dict):
|
||||
|
||||
|
||||
|
||||
def load_watch_from_file(watch_json, uuid, rehydrate_entity_func):
|
||||
class _EntityFileMissing:
|
||||
"""Sentinel: the directory exists but holds no file of this entity type."""
|
||||
__slots__ = ()
|
||||
def __repr__(self):
|
||||
return '<ENTITY_FILE_MISSING>'
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
|
||||
ENTITY_FILE_MISSING = _EntityFileMissing()
|
||||
|
||||
|
||||
def iter_entity_dirs(datastore_path):
|
||||
"""
|
||||
Yield (uuid, dir_path) for every immediate subdirectory of the datastore.
|
||||
|
||||
Replaces glob.glob(f"{datastore_path}/*/watch.json"). glob has to build a
|
||||
regex from the "*" component and fnmatch every name in the directory, then
|
||||
stat each candidate to test the literal "watch.json" part. With ~60k watches
|
||||
that measured as 238k allocations in fnmatch.filter plus one stat per
|
||||
directory, all to produce a list we then split back into uuid + path.
|
||||
|
||||
scandir hands us the names directly, and entry.is_dir() is free on Linux
|
||||
because it reads d_type straight from the dirent (no stat syscall).
|
||||
Symlinked directories are followed, matching glob's behaviour.
|
||||
"""
|
||||
try:
|
||||
with os.scandir(datastore_path) as it:
|
||||
for entry in it:
|
||||
try:
|
||||
if entry.is_dir():
|
||||
yield entry.name, entry.path
|
||||
except OSError:
|
||||
# Raced with a delete, or a broken symlink - not loadable either way
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
|
||||
def load_watch_from_file(watch_json, uuid, rehydrate_entity_func, missing_ok=False):
|
||||
"""
|
||||
Load a watch from its JSON file.
|
||||
|
||||
@@ -216,29 +254,39 @@ def load_watch_from_file(watch_json, uuid, rehydrate_entity_func):
|
||||
watch_json: Path to the watch.json file
|
||||
uuid: Watch UUID
|
||||
rehydrate_entity_func: Function to convert dict to Watch object
|
||||
missing_ok: When True, a missing file returns ENTITY_FILE_MISSING instead of
|
||||
logging an error. Used by load_all_watches, which walks every
|
||||
datastore subdirectory - some of those are tag dirs, not watches.
|
||||
|
||||
Returns:
|
||||
Watch object or None if failed
|
||||
Watch object, ENTITY_FILE_MISSING if absent and missing_ok, else None
|
||||
"""
|
||||
try:
|
||||
# Check file size before reading
|
||||
file_size = os.path.getsize(watch_json)
|
||||
MAX_WATCH_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
if file_size > MAX_WATCH_SIZE:
|
||||
logger.critical(
|
||||
f"CORRUPTED WATCH DATA: Watch {uuid} file is unexpectedly large: "
|
||||
f"{file_size / 1024 / 1024:.2f}MB (max: {MAX_WATCH_SIZE / 1024 / 1024}MB). "
|
||||
f"File: {watch_json}. This indicates a bug or data corruption. "
|
||||
f"Watch will be skipped."
|
||||
)
|
||||
# Open first, then take the size from the already-open fd. Doing it this way
|
||||
# costs one syscall instead of stat()+open(), which matters when this is
|
||||
# called once per directory across a large datastore.
|
||||
try:
|
||||
f = open(watch_json, 'rb')
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
if missing_ok:
|
||||
return ENTITY_FILE_MISSING
|
||||
logger.error(f"Watch file not found: {watch_json} for watch {uuid}")
|
||||
return None
|
||||
|
||||
if HAS_ORJSON:
|
||||
with open(watch_json, 'rb') as f:
|
||||
watch_data = orjson.loads(f.read())
|
||||
else:
|
||||
with open(watch_json, 'r', encoding='utf-8') as f:
|
||||
watch_data = json.load(f)
|
||||
with f:
|
||||
file_size = os.fstat(f.fileno()).st_size
|
||||
MAX_WATCH_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
if file_size > MAX_WATCH_SIZE:
|
||||
logger.critical(
|
||||
f"CORRUPTED WATCH DATA: Watch {uuid} file is unexpectedly large: "
|
||||
f"{file_size / 1024 / 1024:.2f}MB (max: {MAX_WATCH_SIZE / 1024 / 1024}MB). "
|
||||
f"File: {watch_json}. This indicates a bug or data corruption. "
|
||||
f"Watch will be skipped."
|
||||
)
|
||||
return None
|
||||
raw = f.read()
|
||||
|
||||
watch_data = orjson.loads(raw) if HAS_ORJSON else json.loads(raw.decode('utf-8'))
|
||||
|
||||
# Rehydrate and return watch object
|
||||
watch_obj = rehydrate_entity_func(uuid, watch_data)
|
||||
@@ -262,9 +310,6 @@ def load_watch_from_file(watch_json, uuid, rehydrate_entity_func):
|
||||
return None
|
||||
# Re-raise if it's not a JSON parsing error
|
||||
raise
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Watch file not found: {watch_json} for watch {uuid}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load watch {uuid} from {watch_json}: {e}")
|
||||
return None
|
||||
@@ -293,22 +338,27 @@ def load_all_watches(datastore_path, rehydrate_entity_func):
|
||||
if not os.path.exists(datastore_path):
|
||||
return watching
|
||||
|
||||
# Find all watch.json files using glob (faster than manual directory traversal)
|
||||
glob_start = time.perf_counter()
|
||||
watch_files = glob.glob(os.path.join(datastore_path, "*", "watch.json"))
|
||||
glob_time = time.perf_counter() - glob_start
|
||||
# One scandir over the datastore, then open each {uuid}/watch.json directly.
|
||||
# `total` counts candidate directories, so it includes tag dirs that hold no
|
||||
# watch.json - it is an upper bound used only for progress logging.
|
||||
scan_start = time.perf_counter()
|
||||
entity_dirs = list(iter_entity_dirs(datastore_path))
|
||||
scan_time = time.perf_counter() - scan_start
|
||||
|
||||
total = len(watch_files)
|
||||
logger.debug(f"Found {total} watch.json files in {glob_time:.3f}s")
|
||||
total = len(entity_dirs)
|
||||
logger.debug(f"Scanned {total} datastore directories in {scan_time:.3f}s")
|
||||
|
||||
loaded = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
|
||||
for watch_json in watch_files:
|
||||
# Extract UUID from path: /datastore/{uuid}/watch.json
|
||||
uuid_dir = os.path.basename(os.path.dirname(watch_json))
|
||||
watch = load_watch_from_file(watch_json, uuid_dir, rehydrate_entity_func)
|
||||
if watch:
|
||||
for uuid_dir, dir_path in entity_dirs:
|
||||
watch_json = os.path.join(dir_path, "watch.json")
|
||||
watch = load_watch_from_file(watch_json, uuid_dir, rehydrate_entity_func, missing_ok=True)
|
||||
if watch is ENTITY_FILE_MISSING:
|
||||
# Not a watch directory (e.g. holds tag.json instead) - not an error
|
||||
skipped += 1
|
||||
elif watch:
|
||||
watching[uuid_dir] = watch
|
||||
loaded += 1
|
||||
|
||||
@@ -318,6 +368,9 @@ def load_all_watches(datastore_path, rehydrate_entity_func):
|
||||
# load_watch_from_file already logged the specific error
|
||||
failed += 1
|
||||
|
||||
if skipped:
|
||||
logger.debug(f"Skipped {skipped} directories with no watch.json")
|
||||
|
||||
elapsed = time.perf_counter() - start_time
|
||||
load_rate = loaded / elapsed if elapsed > 0 else 0
|
||||
|
||||
@@ -333,7 +386,7 @@ def load_all_watches(datastore_path, rehydrate_entity_func):
|
||||
return watching
|
||||
|
||||
|
||||
def load_tag_from_file(tag_json, uuid, rehydrate_entity_func):
|
||||
def load_tag_from_file(tag_json, uuid, rehydrate_entity_func, missing_ok=False):
|
||||
"""
|
||||
Load a tag from its JSON file.
|
||||
|
||||
@@ -341,29 +394,37 @@ def load_tag_from_file(tag_json, uuid, rehydrate_entity_func):
|
||||
tag_json: Path to the tag.json file
|
||||
uuid: Tag UUID
|
||||
rehydrate_entity_func: Function to convert dict to Tag object
|
||||
missing_ok: When True, a missing file returns ENTITY_FILE_MISSING instead of
|
||||
logging. Used by load_all_tags, which walks every datastore
|
||||
subdirectory - most of those are watch dirs, not tags.
|
||||
|
||||
Returns:
|
||||
Tag object or None if failed
|
||||
Tag object, ENTITY_FILE_MISSING if absent and missing_ok, else None
|
||||
"""
|
||||
try:
|
||||
# Check file size before reading
|
||||
file_size = os.path.getsize(tag_json)
|
||||
MAX_TAG_SIZE = 1 * 1024 * 1024 # 1MB
|
||||
if file_size > MAX_TAG_SIZE:
|
||||
logger.critical(
|
||||
f"CORRUPTED TAG DATA: Tag {uuid} file is unexpectedly large: "
|
||||
f"{file_size / 1024 / 1024:.2f}MB (max: {MAX_TAG_SIZE / 1024 / 1024}MB). "
|
||||
f"File: {tag_json}. This indicates a bug or data corruption. "
|
||||
f"Tag will be skipped."
|
||||
)
|
||||
# See load_watch_from_file: open first, size from the open fd, one syscall.
|
||||
try:
|
||||
f = open(tag_json, 'rb')
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
if missing_ok:
|
||||
return ENTITY_FILE_MISSING
|
||||
logger.debug(f"Tag file not found: {tag_json} for tag {uuid}")
|
||||
return None
|
||||
|
||||
if HAS_ORJSON:
|
||||
with open(tag_json, 'rb') as f:
|
||||
tag_data = orjson.loads(f.read())
|
||||
else:
|
||||
with open(tag_json, 'r', encoding='utf-8') as f:
|
||||
tag_data = json.load(f)
|
||||
with f:
|
||||
file_size = os.fstat(f.fileno()).st_size
|
||||
MAX_TAG_SIZE = 1 * 1024 * 1024 # 1MB
|
||||
if file_size > MAX_TAG_SIZE:
|
||||
logger.critical(
|
||||
f"CORRUPTED TAG DATA: Tag {uuid} file is unexpectedly large: "
|
||||
f"{file_size / 1024 / 1024:.2f}MB (max: {MAX_TAG_SIZE / 1024 / 1024}MB). "
|
||||
f"File: {tag_json}. This indicates a bug or data corruption. "
|
||||
f"Tag will be skipped."
|
||||
)
|
||||
return None
|
||||
raw = f.read()
|
||||
|
||||
tag_data = orjson.loads(raw) if HAS_ORJSON else json.loads(raw.decode('utf-8'))
|
||||
|
||||
tag_data['processor'] = 'restock_diff'
|
||||
# Rehydrate tag (convert dict to Tag object)
|
||||
@@ -389,9 +450,6 @@ def load_tag_from_file(tag_json, uuid, rehydrate_entity_func):
|
||||
return None
|
||||
# Re-raise if it's not a JSON parsing error
|
||||
raise
|
||||
except FileNotFoundError:
|
||||
logger.debug(f"Tag file not found: {tag_json} for tag {uuid}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load tag {uuid} from {tag_json}: {e}")
|
||||
return None
|
||||
@@ -417,23 +475,15 @@ def load_all_tags(datastore_path, rehydrate_entity_func):
|
||||
if not os.path.exists(datastore_path):
|
||||
return tags
|
||||
|
||||
# Find all tag.json files using glob
|
||||
tag_files = glob.glob(os.path.join(datastore_path, "*", "tag.json"))
|
||||
|
||||
total = len(tag_files)
|
||||
if total == 0:
|
||||
logger.debug("No tag.json files found")
|
||||
return tags
|
||||
|
||||
logger.debug(f"Found {total} tag.json files")
|
||||
|
||||
# One scandir over the datastore; most subdirectories are watches, not tags.
|
||||
loaded = 0
|
||||
failed = 0
|
||||
|
||||
for tag_json in tag_files:
|
||||
# Extract UUID from path: /datastore/{uuid}/tag.json
|
||||
uuid_dir = os.path.basename(os.path.dirname(tag_json))
|
||||
tag = load_tag_from_file(tag_json, uuid_dir, rehydrate_entity_func)
|
||||
for uuid_dir, dir_path in iter_entity_dirs(datastore_path):
|
||||
tag_json = os.path.join(dir_path, "tag.json")
|
||||
tag = load_tag_from_file(tag_json, uuid_dir, rehydrate_entity_func, missing_ok=True)
|
||||
if tag is ENTITY_FILE_MISSING:
|
||||
continue
|
||||
if tag:
|
||||
tags[uuid_dir] = tag
|
||||
loaded += 1
|
||||
@@ -441,6 +491,10 @@ def load_all_tags(datastore_path, rehydrate_entity_func):
|
||||
# load_tag_from_file already logged the specific error
|
||||
failed += 1
|
||||
|
||||
if loaded == 0 and failed == 0:
|
||||
logger.debug("No tag.json files found")
|
||||
return tags
|
||||
|
||||
if failed > 0:
|
||||
logger.warning(f"Loaded {loaded} tags, {failed} tags FAILED to load")
|
||||
else:
|
||||
|
||||
@@ -271,13 +271,19 @@
|
||||
<div class="modal-body">
|
||||
<div class="language-list">
|
||||
{% for locale, lang_data in available_languages.items()|sort %}
|
||||
<a href="{{ url_for('set_language', locale=locale, redirect=request.path) }}" class="language-option" data-locale="{{ locale }}">
|
||||
<span class="lang-option {{ lang_data.flag }}"></span> <span class="language-name">{{ lang_data.name }}</span>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('set_language', locale=locale, redirect=request.path) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="language-option" data-locale="{{ locale }}">
|
||||
<span class="lang-option {{ lang_data.flag }}"></span> <span class="language-name">{{ lang_data.name }}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('ui.delete_locale_language_session_var_if_it_exists', redirect=request.path) }}" >{{ _('Auto-detect from browser') }}</a>
|
||||
<form method="POST" action="{{ url_for('ui.delete_locale_language_session_var_if_it_exists', redirect=request.path) }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="bare-btn">{{ _('Auto-detect from browser') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
{{ _('Language support is in beta, please help us improve by opening a PR on GitHub with any updates.') }}
|
||||
|
||||
@@ -5,59 +5,130 @@
|
||||
llm_configured — bool: LLM provider is configured in settings
|
||||
form — the WTForms form (must have .llm_intent and .llm_change_summary fields)
|
||||
|
||||
Optional (watch edit only):
|
||||
Group/tag edit only:
|
||||
llm_group_edit — bool: True when rendering the tag/group edit page. This is the ONLY
|
||||
way to tell the two contexts apart — the tags blueprint also passes
|
||||
the tag dict as `watch`, so `watch` is truthy in both.
|
||||
form.llm_backend_profile is ternary here (On / Off / Leave it to each
|
||||
watch) and is the group's only AI control; on a watch it is a checkbox.
|
||||
|
||||
Watch edit only:
|
||||
watch — the Watch object (for processor check and prefilter display)
|
||||
llm_group_overrides — dict returned by _resolve_llm_group_overrides():
|
||||
{'llm_intent': {'value': str, 'group_name': str} | None,
|
||||
'llm_change_summary': {'value': str, 'group_name': str} | None}
|
||||
Present only in watch edit context; absent in tag edit context.
|
||||
'llm_change_summary': {'value': str, 'group_name': str} | None,
|
||||
'llm_backend_profile': {'value': bool, 'group_name': str} | None}
|
||||
The two prompt entries are non-None only when the watch has no own
|
||||
value AND a linked group set to "On" has one — that is what puts
|
||||
"From group '<name>': <value>" in the placeholder.
|
||||
llm_backend_profile is non-None when a linked group has taken the
|
||||
on/off decision (On or Off, not "leave it to each watch") — #4204.
|
||||
|
||||
Usage in watch edit (edit.html):
|
||||
{% include "edit/include_llm_intent.html" %}
|
||||
|
||||
Usage in tag edit (edit-tag.html):
|
||||
{% include "edit/include_llm_intent.html" %}
|
||||
(watch is not set → tag mode: no processor check, no examples, different description)
|
||||
Usage (both): {% include "edit/include_llm_intent.html" %}
|
||||
#}
|
||||
{% from '_helpers.html' import render_field %}
|
||||
{% from '_helpers.html' import render_field, render_checkbox_field, render_ternary_field %}
|
||||
|
||||
{# Processor check only applies in watch-edit context (llm_group_overrides present). #}
|
||||
{# In tag/group edit context the AI section is always visible. #}
|
||||
{% set llm_group_mode = llm_group_edit|default(false) %}
|
||||
|
||||
{# Processor check only applies in watch-edit context. #}
|
||||
{# In tag/group edit context the AI section is always visible. #}
|
||||
{# Processors whose edit form carries the AI Intent / Change Summary fields (i.e. those whose
|
||||
form inherits processor_text_json_diff_form). text_json_diff + restock_diff today. #}
|
||||
{% if llm_group_overrides is defined %}
|
||||
{% set show_ai_section = not watch.get('processor') or watch.get('processor') in ['text_json_diff', 'restock_diff'] %}
|
||||
{% else %}
|
||||
{% if llm_group_mode %}
|
||||
{% set show_ai_section = true %}
|
||||
{% else %}
|
||||
{% set show_ai_section = not watch.get('processor') or watch.get('processor') in ['text_json_diff', 'restock_diff'] %}
|
||||
{% endif %}
|
||||
|
||||
{# An unrendered switch is absent from the POST, and WTForms reads that as off. So whenever we
|
||||
do NOT render the AI switch (no LLM provider configured, or a processor whose form has no AI
|
||||
fields) we carry its saved state through in a hidden input — otherwise merely saving the page
|
||||
would silently switch AI off. #}
|
||||
{% if not (show_ai_section and llm_configured) %}
|
||||
{% if llm_group_mode %}
|
||||
{# Group: ternary, so preserve whichever of the three states it is in #}
|
||||
{% if form.llm_backend_profile.data is not none %}<input type="hidden" name="llm_backend_profile" value="{{ 'true' if form.llm_backend_profile.data else 'false' }}">{% endif %}
|
||||
{% elif form.llm_backend_profile.data %}
|
||||
<input type="hidden" name="llm_backend_profile" value="y">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_ai_section %}
|
||||
|
||||
{# ── Configured: show the intent + summary fields ────────────────── #}
|
||||
{% if llm_configured %}
|
||||
<div class="border-fieldset" id="llm-intent-section">
|
||||
<h3>✨ {{ _('AI') }}</h3>
|
||||
<div id="llm-intent-section">
|
||||
|
||||
{# — AI Change Intent — #}
|
||||
<h4 style="margin: 0 0 0.3em 0;">{{ _('AI — Notify when…') }}</h4>
|
||||
{% if llm_group_mode %}
|
||||
{# The group's single AI control. "On" is also what makes the prompts below cascade to the
|
||||
watches (tag_llm_applies_to_watches), so there is no second "does this override?" flag. #}
|
||||
<div class="pure-control-group inline-radio" id="llm-ai-enabled-row">
|
||||
{{ render_ternary_field(form.llm_backend_profile) }}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('<strong>On</strong> – every watch in this group uses the AI settings below, unless it fills in its own. <strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – this group has no say; each watch uses its own AI settings.')|safe }}
|
||||
</span>
|
||||
</div>
|
||||
{# The prompts below only mean something in the "On" state, so grey them out otherwise — the
|
||||
radio counterpart of the toggleOpacity cue used by #overrides_watch on the restock tab. #}
|
||||
<script id="llm-group-opacity-toggle">
|
||||
$(document).ready(function () {
|
||||
toggleOpacityByRadioValue('llm_backend_profile', 'true', '#change-intent-notify-me-when, #change-summary');
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
{# Per-watch AI on/off (#4204). Resolution is global settings → group → watch, so a group that
|
||||
has taken the decision (its AI setting is On or Off rather than "leave it to each watch")
|
||||
owns this control: we disable it, show the state the group decided, and name the group.
|
||||
The watch's own preference is not lost — because the field isn't user-writable in this
|
||||
state, edit.py ignores whatever the POST says for it and keeps the stored value. #}
|
||||
{% set profile_group = llm_group_overrides.llm_backend_profile if llm_group_overrides is defined else none %}
|
||||
<div class="pure-control-group" id="llm-ai-enabled-row">
|
||||
{# Only the checkbox is dimmed — the explanation of *why* has to stay readable. #}
|
||||
<div{% if profile_group %} style="opacity: 0.6;"{% endif %}>
|
||||
{% if profile_group %}
|
||||
{# Show what the group decided, not this watch's now-inert own value. #}
|
||||
{% set dummy = form.llm_backend_profile.__setattr__('checked', profile_group.value) %}
|
||||
{{ render_checkbox_field(form.llm_backend_profile, disabled=True) }}
|
||||
{% else %}
|
||||
{{ render_checkbox_field(form.llm_backend_profile) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if profile_group %}
|
||||
{# The group name links to its edit page so the decision can be changed where it lives.
|
||||
Built as escaped markup and passed into the sentence, so translators keep one string. #}
|
||||
{%- set group_link -%}
|
||||
<a href="{{ url_for('tags.form_tag_edit', uuid=profile_group.group_uuid) }}#ai-llm">{{ profile_group.group_name }}</a>
|
||||
{%- endset -%}
|
||||
<span class="pure-form-message-inline">
|
||||
{% if profile_group.value %}
|
||||
{{ _("Group %(name)s decides this: AI is ON for every watch in that group.", name=group_link) | safe }}
|
||||
{% else %}
|
||||
{{ _("Group %(name)s decides this: AI is OFF for every watch in that group.", name=group_link) | safe }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="border-fieldset" id="change-intent-notify-me-when">
|
||||
<p class="pure-form-message-inline" style="margin-top:0">
|
||||
{% if watch is defined and watch %}
|
||||
{% if not llm_group_mode %}
|
||||
{{ _('Describe what you care about. The AI evaluates every detected change against this and only notifies you when it matches.') }}
|
||||
{% else %}
|
||||
{{ _('Set a change intent for all watches in this tag/group. Each watch can override with its own.') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="pure-control-group">
|
||||
{% if watch is defined and watch and llm_group_overrides is defined and llm_group_overrides.llm_intent %}
|
||||
{% if not llm_group_mode and llm_group_overrides is defined and llm_group_overrides.llm_intent %}
|
||||
{% set intent_placeholder = _("From group '%(name)s': %(value)s", name=llm_group_overrides.llm_intent.group_name, value=llm_group_overrides.llm_intent.value) %}
|
||||
{% elif watch is defined and watch %}
|
||||
{% elif not llm_group_mode %}
|
||||
{% set intent_placeholder = _('e.g. Alert me when the price drops below $300, or a new product is launched. Ignore footer and navigation changes.') %}
|
||||
{% else %}
|
||||
{% set intent_placeholder = _('e.g. Flag price changes or new product launches across all watches in this group') %}
|
||||
{% endif %}
|
||||
{{ render_field(form.llm_intent, placeholder=intent_placeholder, rows=5, class="pure-input-1") }}
|
||||
</div>
|
||||
{% if watch is defined and watch %}
|
||||
{% if not llm_group_mode %}
|
||||
<div class="pure-form-message-inline">
|
||||
<strong>{{ _('Examples:') }}</strong>
|
||||
<ul style="margin: 0.3em 0 0 1.2em; padding: 0;">
|
||||
@@ -67,19 +138,19 @@
|
||||
<li><em>{{ _('Only important if package versions change or a CVE is mentioned') }}</em></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% if watch.get('llm_prefilter') %}
|
||||
{% if watch is defined and watch.get('llm_prefilter') %}
|
||||
<div class="pure-form-message-inline" style="margin-top: 0.5em;">
|
||||
<small>{{ _('AI pre-filter active: <code>%(filter)s</code> — narrows content scope before evaluation', filter=watch.get('llm_prefilter')|e) | safe }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<hr style="margin: 1.2em 0; border: none; border-top: 1px solid var(--color-border, #ddd);">
|
||||
</div>
|
||||
<div class="border-fieldset" id="change-summary">
|
||||
|
||||
{# — AI Change Summary — #}
|
||||
<h4 style="margin: 0 0 0.3em 0;">{{ _('AI Change Summary') }}</h4>
|
||||
<p class="pure-form-message-inline" style="margin-top:0">
|
||||
{% if watch is defined and watch %}
|
||||
{% if not llm_group_mode %}
|
||||
{{ _('When a change is detected, the AI describes it according to your instructions and replaces <code>%(diff)s</code> in your notification. Use <code>%(raw_diff)s</code> if you still want the original diff.',
|
||||
diff='{{diff}}', raw_diff='{{raw_diff}}') | safe }}
|
||||
{% else %}
|
||||
@@ -87,34 +158,30 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="pure-control-group">
|
||||
{% if watch is defined and watch and llm_group_overrides is defined and llm_group_overrides.llm_change_summary %}
|
||||
{% if not llm_group_mode and llm_group_overrides is defined and llm_group_overrides.llm_change_summary %}
|
||||
{% set summary_placeholder = _("From group '%(name)s': %(value)s", name=llm_group_overrides.llm_change_summary.group_name, value=llm_group_overrides.llm_change_summary.value) %}
|
||||
{% else %}
|
||||
{% set summary_placeholder = form.llm_change_summary.render_kw['placeholder'] %}
|
||||
{% endif %}
|
||||
{{ render_field(form.llm_change_summary, placeholder=summary_placeholder, rows=5, class="pure-input-1") }}
|
||||
</div>
|
||||
<div style="margin-top: 0.3em;">
|
||||
|
||||
<a href="#" class="pure-button button-xsmall" onclick="var t=document.getElementById('llm_change_summary'); if(!t.value&&t.placeholder) t.value=t.placeholder; return false;">{{ _('Modify default prompt') }}</a>
|
||||
</div>
|
||||
<div class="pure-control-group" style="margin-top: 0.6em;">
|
||||
<label>{{ form.llm_change_summary_mode.label.text }}</label>
|
||||
<div>
|
||||
{% for subfield in form.llm_change_summary_mode %}
|
||||
<label class="pure-radio" style="display:block; font-weight:normal; margin-bottom:0.3em;">
|
||||
{{ subfield() }} {{ subfield.label.text }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<br>
|
||||
<div class="inline-radio">
|
||||
{{ render_field(form.llm_change_summary_mode) }}
|
||||
<span class="pure-form-message-inline">
|
||||
{% if not llm_group_mode %}
|
||||
{{ _('Appending keeps the prompt inherited from the group or from global settings and adds your text after it, so later edits to that prompt still reach this watch.') }}
|
||||
{% else %}
|
||||
{{ _('Appending keeps the prompt inherited from global settings and adds your text after it, so later edits to that prompt still reach this group.') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<span class="pure-form-message-inline">
|
||||
{% if watch is defined and watch %}
|
||||
{{ _('Appending keeps the prompt inherited from the group or from global settings and adds your text after it, so later edits to that prompt still reach this watch.') }}
|
||||
{% else %}
|
||||
{{ _('Appending keeps the prompt inherited from global settings and adds your text after it, so later edits to that prompt still reach this group.') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if watch is defined and watch %}
|
||||
|
||||
|
||||
|
||||
{% if not llm_group_mode %}
|
||||
<div class="pure-form-message-inline">
|
||||
<strong>{{ _('Examples:') }}</strong>
|
||||
<ul style="margin: 0.3em 0 0 1.2em; padding: 0;">
|
||||
@@ -125,13 +192,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Not configured: greyed-out prompt to configure ──────────────── #}
|
||||
{% else %}
|
||||
<div class="border-fieldset" id="llm-intent-section-disabled" style="opacity: 0.5;">
|
||||
<h3>✨ {{ _('AI') }}</h3>
|
||||
<p>
|
||||
{% if watch is defined and watch %}
|
||||
{% if not llm_group_mode %}
|
||||
{{ _('Configure an AI / LLM provider in <a href="%(url)s">Settings → AI / LLM</a> to enable AI Change Intent and AI Change Summary.',
|
||||
url=url_for('settings.settings_page') + '#ai') | safe }}
|
||||
{% else %}
|
||||
|
||||
@@ -5,14 +5,23 @@
|
||||
{% if current_user.is_authenticated or not has_password %}
|
||||
{% if not current_diff_url %}
|
||||
<li class="pure-menu-item" id="menu-pause">
|
||||
<a class="status-pill {{ 'paused' if all_paused }}" href="{{ url_for('settings.toggle_all_paused') }}" aria-label="{% if all_paused %}{{ _('Resume automatic scheduling') }}{% else %}{{ _('Pause auto-queue scheduling of watches') }}{% endif %}" title="{% if all_paused %}{{ _('Scheduling paused — click to resume') }}{% else %}{{ _('Scheduling active — click to pause all') }}{% endif %}"><span class="live-dot"></span>{% if all_paused %}{{ _('Paused') }}{% else %}{{ _('Running') }}{% endif %}</a>
|
||||
<form method="POST" action="{{ url_for('settings.toggle_all_paused') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="status-pill {{ 'paused' if all_paused }}" aria-label="{% if all_paused %}{{ _('Resume automatic scheduling') }}{% else %}{{ _('Pause auto-queue scheduling of watches') }}{% endif %}" title="{% if all_paused %}{{ _('Scheduling paused — click to resume') }}{% else %}{{ _('Scheduling active — click to pause all') }}{% endif %}"><span class="live-dot"></span>{% if all_paused %}{{ _('Paused') }}{% else %}{{ _('Running') }}{% endif %}</button>
|
||||
</form>
|
||||
</li>
|
||||
<li class="pure-menu-item " id="menu-mute">
|
||||
<a class="status-pill {{ 'muted' if all_muted }}" href="{{ url_for('settings.toggle_all_muted') }}" aria-label="{% if all_muted %}{{ _('Unmute notifications') }}{% else %}{{ _('Mute notifications') }}{% endif %}" title="{% if all_muted %}{{ _('Notifications are muted - click to unmute') }}{% else %}{{ _('Mute notifications') }}{% endif %}"><i data-feather="{{ 'bell-off' if all_muted else 'bell' }}" class="action-icon"></i>{% if all_muted %}{{ _('Muted') }}{% else %}{{ _('Alerts on') }}{% endif %}</a>
|
||||
<form method="POST" action="{{ url_for('settings.toggle_all_muted') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="status-pill {{ 'muted' if all_muted }}" aria-label="{% if all_muted %}{{ _('Unmute notifications') }}{% else %}{{ _('Mute notifications') }}{% endif %}" title="{% if all_muted %}{{ _('Notifications are muted - click to unmute') }}{% else %}{{ _('Mute notifications') }}{% endif %}"><i data-feather="{{ 'bell-off' if all_muted else 'bell' }}" class="action-icon"></i>{% if all_muted %}{{ _('Muted') }}{% else %}{{ _('Alerts on') }}{% endif %}</button>
|
||||
</form>
|
||||
</li>
|
||||
{%- if current_user.is_authenticated -%}
|
||||
<li class="pure-menu-item menu-collapsible">
|
||||
<a href="{{ url_for('logout', redirect=request.path) }}" ><i data-feather="log-out" class="action-icon"></i> {{ _('Log out') }}</a>
|
||||
<form method="POST" action="{{ url_for('logout', redirect=request.path) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="bare-btn"><i data-feather="log-out" class="action-icon"></i> {{ _('Log out') }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{%- endif -%}
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
<li class="action-sidebar-li" id="action-sidebar-logo">
|
||||
|
||||
{%- if has_password and not current_user.is_authenticated -%}
|
||||
<a id="cdio-logo" href="https://changedetection.io" rel="noopener">
|
||||
<a id="cdio-logo" href="https://changedetection.io" rel="noopener" title="ChangeDetection.io intelligent web page change detection.">
|
||||
<span id="logo-expanded"><strong>Change</strong>Detection.io</span>
|
||||
<span id="logo-short"><strong>CD</strong>IO</span>
|
||||
</a>
|
||||
{%- else -%}
|
||||
<a id="cdio-logo" href="{{url_for('watchlist.index')}}">
|
||||
<a id="cdio-logo" href="{{url_for('watchlist.index')}}" title="ChangeDetection.io intelligent web page change detection.">
|
||||
<span id="logo-expanded"><strong>Change</strong>Detection.io</span>
|
||||
<span id="logo-short"><strong>CD</strong>IO</span>
|
||||
</a>
|
||||
|
||||
@@ -132,8 +132,9 @@ def environment(mocker):
|
||||
# Fixed datetime: Wed, 09 Dec 2015 23:33:01 UTC
|
||||
# This is calculated to match the test expectations when offsets are applied
|
||||
fixed_datetime = arrow.Arrow(2015, 12, 9, 23, 33, 1, tzinfo='UTC')
|
||||
# Patch arrow.now in the TimeExtension module where it's actually used
|
||||
mocker.patch('changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now', return_value=fixed_datetime)
|
||||
# Patch on the arrow module itself - TimeExtension imports arrow lazily inside its methods
|
||||
# (import diet), so there is no module-level TimeExtension.arrow attribute to patch
|
||||
mocker.patch('arrow.now', return_value=fixed_datetime)
|
||||
return fixed_datetime
|
||||
|
||||
|
||||
@@ -202,7 +203,7 @@ def pytest_configure(config):
|
||||
LiveServer uses nested functions that can't be pickled.
|
||||
|
||||
Setting 'fork' explicitly:
|
||||
- Maintains compatibility with Python 3.10-3.13 (where 'fork' was already default)
|
||||
- Maintains compatibility with Python 3.11-3.13 (where 'fork' was already default)
|
||||
- Fixes Python 3.14 pickling errors
|
||||
- Only affects Unix-like systems (Windows uses 'spawn' regardless)
|
||||
|
||||
|
||||
@@ -22,6 +22,19 @@ def _make_datastore(llm_cfg=None, tags=None):
|
||||
return ds
|
||||
|
||||
|
||||
def _make_tag(ai=True, **fields):
|
||||
"""Build a tag dict.
|
||||
|
||||
`ai` is the group's single AI control (llm_backend_profile): True = on, and its AI
|
||||
settings apply to its watches; False = off for every watch in the group; None = the
|
||||
group has no say. Defaults to True because most cases here are about what a group set
|
||||
to "On" does.
|
||||
"""
|
||||
tag = {'title': 'grp', 'llm_backend_profile': ai}
|
||||
tag.update(fields)
|
||||
return tag
|
||||
|
||||
|
||||
def _make_watch(llm_intent='', llm_change_summary='', tags=None, uuid='test-uuid-1234'):
|
||||
w = {}
|
||||
w['llm_intent'] = llm_intent
|
||||
@@ -43,7 +56,7 @@ class TestResolveIntent:
|
||||
def test_watch_intent_takes_priority(self):
|
||||
from changedetectionio.llm.evaluator import resolve_intent
|
||||
|
||||
tag = {'title': 'mygroup', 'llm_intent': 'group intent'}
|
||||
tag = _make_tag(title='mygroup', llm_intent='group intent')
|
||||
ds = _make_datastore(tags={'tag-1': tag})
|
||||
watch = _make_watch(llm_intent='watch intent', tags=['tag-1'])
|
||||
|
||||
@@ -54,7 +67,7 @@ class TestResolveIntent:
|
||||
def test_tag_intent_used_when_watch_has_none(self):
|
||||
from changedetectionio.llm.evaluator import resolve_intent
|
||||
|
||||
tag = {'title': 'pricing-group', 'llm_intent': 'flag price drops'}
|
||||
tag = _make_tag(title='pricing-group', llm_intent='flag price drops')
|
||||
ds = _make_datastore(tags={'tag-1': tag})
|
||||
watch = _make_watch(llm_intent='', tags=['tag-1'])
|
||||
|
||||
@@ -73,10 +86,10 @@ class TestResolveIntent:
|
||||
assert source == ''
|
||||
|
||||
def test_tag_applied_to_all_watches_in_group(self):
|
||||
"""Tag intent propagates to every watch in the tag (no opt-in needed)."""
|
||||
"""An opted-in tag's intent propagates to every watch in the tag."""
|
||||
from changedetectionio.llm.evaluator import resolve_intent
|
||||
|
||||
tag = {'title': 'job-board', 'llm_intent': 'new engineering jobs'}
|
||||
tag = _make_tag(title='job-board', llm_intent='new engineering jobs')
|
||||
ds = _make_datastore(tags={'tag-1': tag})
|
||||
|
||||
# Three different watches, all in the tag, none have their own intent
|
||||
@@ -103,6 +116,114 @@ class TestResolveIntent:
|
||||
assert intent == ''
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The group's AI setting gates the whole cascade
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGroupAiSettingGatesTheCascade:
|
||||
def test_only_the_on_state_hands_settings_down(self):
|
||||
from changedetectionio.llm.evaluator import tag_llm_applies_to_watches
|
||||
assert tag_llm_applies_to_watches(_make_tag(ai=True)) is True
|
||||
# "Off" suppresses AI rather than lending prompts; "leave it to each watch" and tags
|
||||
# predating the setting (and missing tags) hand nothing down either
|
||||
assert tag_llm_applies_to_watches(_make_tag(ai=False)) is False
|
||||
assert tag_llm_applies_to_watches(_make_tag(ai=None)) is False
|
||||
assert tag_llm_applies_to_watches({'title': 'legacy'}) is False
|
||||
assert tag_llm_applies_to_watches(None) is False
|
||||
|
||||
def test_intent_not_inherited_when_group_is_off(self):
|
||||
from changedetectionio.llm.evaluator import resolve_intent
|
||||
tag = _make_tag(ai=False, title='pricing-group', llm_intent='flag price drops')
|
||||
ds = _make_datastore(tags={'tag-1': tag})
|
||||
watch = _make_watch(llm_intent='', tags=['tag-1'])
|
||||
assert resolve_intent(watch, ds) == ('', '')
|
||||
|
||||
def test_field_not_inherited_when_group_is_off(self):
|
||||
from changedetectionio.llm.evaluator import resolve_llm_field
|
||||
tag = _make_tag(ai=False, llm_change_summary='list new events')
|
||||
ds = _make_datastore(tags={'t1': tag})
|
||||
watch = _make_watch(llm_change_summary='', tags=['t1'])
|
||||
assert resolve_llm_field(watch, ds, 'llm_change_summary') == ('', '')
|
||||
|
||||
def test_summary_prompt_not_inherited_when_group_is_off(self):
|
||||
from changedetectionio.llm.evaluator import get_effective_summary_prompt
|
||||
tag = _make_tag(ai=False, llm_change_summary='TAG')
|
||||
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
|
||||
watch = _make_watch(llm_change_summary='', tags=['t1'])
|
||||
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL'
|
||||
|
||||
def test_first_group_set_to_on_wins_over_an_earlier_one_that_is_off(self):
|
||||
"""A group that isn't "On" is skipped entirely, not treated as "found, but empty"."""
|
||||
from changedetectionio.llm.evaluator import resolve_intent
|
||||
ds = _make_datastore(tags={
|
||||
'ignored': _make_tag(ai=False, title='ignored-group', llm_intent='IGNORED'),
|
||||
'used': _make_tag(ai=True, title='used-group', llm_intent='USED'),
|
||||
})
|
||||
watch = _make_watch(llm_intent='', tags=['ignored', 'used'])
|
||||
assert resolve_intent(watch, ds) == ('USED', 'used-group')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# llm_enabled_for_watch — per-watch / per-group AI on-off switch (#4204)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLlmEnabledForWatch:
|
||||
def test_enabled_by_default(self):
|
||||
"""Watches predating the switch (no key at all) keep AI on."""
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
ds = _make_datastore()
|
||||
assert llm_enabled_for_watch(_make_watch(), ds) == (True, 'watch')
|
||||
|
||||
def test_watch_switch_off(self):
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
ds = _make_datastore()
|
||||
watch = _make_watch()
|
||||
watch['llm_backend_profile'] = False
|
||||
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
|
||||
|
||||
def test_group_switch_overrides_watch_off(self):
|
||||
""""The group setting overrides any watch on/off" — group ON beats watch OFF."""
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
tag = _make_tag(ai=True, title='ai-group')
|
||||
ds = _make_datastore(tags={'t1': tag})
|
||||
watch = _make_watch(tags=['t1'])
|
||||
watch['llm_backend_profile'] = False
|
||||
assert llm_enabled_for_watch(watch, ds) == (True, 'ai-group')
|
||||
|
||||
def test_group_switch_overrides_watch_on(self):
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
tag = _make_tag(ai=False, title='no-ai-group')
|
||||
ds = _make_datastore(tags={'t1': tag})
|
||||
watch = _make_watch(tags=['t1'])
|
||||
watch['llm_backend_profile'] = True
|
||||
assert llm_enabled_for_watch(watch, ds) == (False, 'no-ai-group')
|
||||
|
||||
def test_group_leaving_it_to_each_watch_does_not_decide(self):
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
ds = _make_datastore(tags={'t1': _make_tag(ai=None, title='undecided-group')})
|
||||
watch = _make_watch(tags=['t1'])
|
||||
watch['llm_backend_profile'] = False
|
||||
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
|
||||
|
||||
def test_group_without_the_key_leaves_it_to_the_watch(self):
|
||||
"""Tags predating the setting behave like "leave it to each watch"."""
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
ds = _make_datastore(tags={'t1': {'title': 'legacy'}})
|
||||
watch = _make_watch(tags=['t1'])
|
||||
watch['llm_backend_profile'] = False
|
||||
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
|
||||
|
||||
def test_first_deciding_group_wins(self):
|
||||
"""An undecided group is skipped; the next group with an opinion decides."""
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
ds = _make_datastore(tags={
|
||||
'a': _make_tag(ai=None, title='undecided-group'),
|
||||
'b': _make_tag(ai=False, title='no-ai-group'),
|
||||
})
|
||||
watch = _make_watch(tags=['a', 'b'])
|
||||
assert llm_enabled_for_watch(watch, ds) == (False, 'no-ai-group')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_llm_config
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -386,7 +507,7 @@ class TestTokenBudget:
|
||||
class TestResolveLlmField:
|
||||
def test_watch_value_takes_priority(self):
|
||||
from changedetectionio.llm.evaluator import resolve_llm_field
|
||||
tag = {'title': 'mygroup', 'llm_change_summary': 'tag summary prompt'}
|
||||
tag = _make_tag(title='mygroup', llm_change_summary='tag summary prompt')
|
||||
ds = _make_datastore(tags={'tag-1': tag})
|
||||
watch = _make_watch(llm_change_summary='watch summary prompt', tags=['tag-1'])
|
||||
value, source = resolve_llm_field(watch, ds, 'llm_change_summary')
|
||||
@@ -395,7 +516,7 @@ class TestResolveLlmField:
|
||||
|
||||
def test_tag_value_used_when_watch_empty(self):
|
||||
from changedetectionio.llm.evaluator import resolve_llm_field
|
||||
tag = {'title': 'events-group', 'llm_change_summary': 'list new events'}
|
||||
tag = _make_tag(title='events-group', llm_change_summary='list new events')
|
||||
ds = _make_datastore(tags={'tag-1': tag})
|
||||
watch = _make_watch(llm_change_summary='', tags=['tag-1'])
|
||||
value, source = resolve_llm_field(watch, ds, 'llm_change_summary')
|
||||
@@ -413,7 +534,7 @@ class TestResolveLlmField:
|
||||
def test_works_for_llm_intent_field_too(self):
|
||||
"""resolve_llm_field is generic — works for llm_intent same as llm_change_summary."""
|
||||
from changedetectionio.llm.evaluator import resolve_llm_field
|
||||
tag = {'title': 'grp', 'llm_intent': 'flag price drops'}
|
||||
tag = _make_tag(llm_intent='flag price drops')
|
||||
ds = _make_datastore(tags={'t1': tag})
|
||||
watch = _make_watch(llm_intent='', tags=['t1'])
|
||||
value, source = resolve_llm_field(watch, ds, 'llm_intent')
|
||||
@@ -469,7 +590,7 @@ class TestSummariseChange:
|
||||
def test_cascades_from_tag(self):
|
||||
"""llm_change_summary on a tag propagates to watches in that tag."""
|
||||
from changedetectionio.llm.evaluator import summarise_change
|
||||
tag = {'title': 'events', 'llm_change_summary': 'Translate events to English'}
|
||||
tag = _make_tag(title='events', llm_change_summary='Translate events to English')
|
||||
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}, tags={'tag-1': tag})
|
||||
watch = _make_watch(llm_change_summary='', tags=['tag-1'])
|
||||
with patch('changedetectionio.llm.client.completion',
|
||||
@@ -552,7 +673,7 @@ class TestSummaryCacheKey:
|
||||
|
||||
def test_get_effective_prompt_cascades_from_tag(self):
|
||||
from changedetectionio.llm.evaluator import get_effective_summary_prompt
|
||||
tag = {'title': 'grp', 'llm_change_summary': 'tag-level prompt'}
|
||||
tag = _make_tag(llm_change_summary='tag-level prompt')
|
||||
ds = _make_datastore(tags={'t1': tag})
|
||||
watch = _make_watch(llm_change_summary='', tags=['t1'])
|
||||
assert get_effective_summary_prompt(watch, ds) == 'tag-level prompt'
|
||||
@@ -592,7 +713,7 @@ class TestSummaryPromptAppendMode:
|
||||
def test_watch_append_targets_the_tag_prompt_when_a_tag_supplies_one(self):
|
||||
"""The watch appends to what it would otherwise have inherited — here the tag."""
|
||||
from changedetectionio.llm.evaluator import get_effective_summary_prompt
|
||||
tag = {'title': 'grp', 'llm_change_summary': 'TAG'}
|
||||
tag = _make_tag(llm_change_summary='TAG')
|
||||
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
|
||||
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
|
||||
watch['llm_change_summary_mode'] = 'append'
|
||||
@@ -600,7 +721,7 @@ class TestSummaryPromptAppendMode:
|
||||
|
||||
def test_tag_and_watch_can_both_append_forming_a_chain(self):
|
||||
from changedetectionio.llm.evaluator import get_effective_summary_prompt
|
||||
tag = {'title': 'grp', 'llm_change_summary': 'TAG', 'llm_change_summary_mode': 'append'}
|
||||
tag = _make_tag(llm_change_summary='TAG', llm_change_summary_mode='append')
|
||||
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
|
||||
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
|
||||
watch['llm_change_summary_mode'] = 'append'
|
||||
@@ -608,7 +729,7 @@ class TestSummaryPromptAppendMode:
|
||||
|
||||
def test_tag_appends_while_watch_replaces(self):
|
||||
from changedetectionio.llm.evaluator import get_effective_summary_prompt
|
||||
tag = {'title': 'grp', 'llm_change_summary': 'TAG', 'llm_change_summary_mode': 'append'}
|
||||
tag = _make_tag(llm_change_summary='TAG', llm_change_summary_mode='append')
|
||||
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
|
||||
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
|
||||
assert get_effective_summary_prompt(watch, ds) == 'WATCH'
|
||||
|
||||
@@ -3,16 +3,37 @@ Unit tests for changedetectionio/llm/prompt_builder.py
|
||||
|
||||
All functions are pure — no external dependencies needed.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from changedetectionio.llm.prompt_builder import (
|
||||
_annotate_moved_lines,
|
||||
build_eval_prompt,
|
||||
build_eval_system_prompt,
|
||||
build_setup_prompt,
|
||||
build_setup_system_prompt,
|
||||
SNAPSHOT_CONTEXT_CHARS,
|
||||
)
|
||||
|
||||
|
||||
class TestAnnotateMovedLines:
|
||||
def test_annotate_moved_lines_marks_reordered_content(self):
|
||||
diff = "- Item Alpha\n+ Item Beta\n+ Item Alpha\n- Item Beta"
|
||||
annotated = _annotate_moved_lines(diff)
|
||||
assert "~ Item Alpha" in annotated
|
||||
assert "~ Item Beta" in annotated
|
||||
|
||||
def test_annotate_standalone_timestamp_without_moved_lines(self):
|
||||
# Even when there are NO moved lines, standalone relative timestamps must be annotated
|
||||
diff = "- 2 hours ago\n+ 3 hours ago\n+ Genuine new article headline"
|
||||
annotated = _annotate_moved_lines(diff)
|
||||
assert "~ 2 hours ago" in annotated
|
||||
assert "~ 3 hours ago" in annotated
|
||||
assert "+ Genuine new article headline" in annotated
|
||||
|
||||
def test_unrelated_diff_remains_unchanged(self):
|
||||
diff = "- Old price: $100\n+ New price: $80"
|
||||
annotated = _annotate_moved_lines(diff)
|
||||
assert annotated == diff
|
||||
|
||||
|
||||
class TestBuildEvalPrompt:
|
||||
def test_contains_intent(self):
|
||||
prompt = build_eval_prompt(intent='Alert on price drops', diff='- $500\n+ $400')
|
||||
|
||||
@@ -3,10 +3,13 @@ Unit tests for changedetectionio/llm/response_parser.py
|
||||
|
||||
All functions are pure — no external dependencies needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from changedetectionio.llm.response_parser import (
|
||||
_extract_json,
|
||||
parse_eval_response,
|
||||
parse_preview_response,
|
||||
parse_setup_response,
|
||||
)
|
||||
|
||||
@@ -37,6 +40,86 @@ class TestExtractJson:
|
||||
result = _extract_json(raw)
|
||||
assert '"important"' in result
|
||||
|
||||
def test_strips_reasoning_think_tags(self):
|
||||
raw = (
|
||||
'<think>\n'
|
||||
'Let us consider if {"important": false} is right. Actually, yes.\n'
|
||||
'</think>\n'
|
||||
'{"important": true, "summary": "Price fell to $300"}'
|
||||
)
|
||||
result = _extract_json(raw)
|
||||
assert result == '{"important": true, "summary": "Price fell to $300"}'
|
||||
|
||||
def test_strips_reasoning_think_tags_with_code_fence(self):
|
||||
raw = (
|
||||
'<think>\nThinking about the price change.\n</think>\n'
|
||||
'```json\n{"important": false, "summary": "Cosmetic change only"}\n```'
|
||||
)
|
||||
result = _extract_json(raw)
|
||||
assert '"important"' in result
|
||||
assert '<think>' not in result
|
||||
|
||||
|
||||
class TestReasoningBlockEdgeCases:
|
||||
"""A reasoning scratchpad usually contains JSON of its own, so any leftover scratchpad
|
||||
lets _extract_json return a discarded intermediate answer. Every shape below carries a
|
||||
misleading `"important": false` in the scratchpad and the real verdict outside it."""
|
||||
|
||||
def test_closing_tag_only_is_still_stripped(self):
|
||||
# Several providers/chat templates inject the opening tag themselves, so only the
|
||||
# closer comes back over the wire.
|
||||
raw = (
|
||||
'My first read was {"important": false, "summary": "nothing"}\n'
|
||||
'</think>\n'
|
||||
'{"important": true, "summary": "Price dropped"}'
|
||||
)
|
||||
assert _extract_json(raw) == '{"important": true, "summary": "Price dropped"}'
|
||||
assert parse_eval_response(raw) == {
|
||||
'important': True,
|
||||
'summary': 'Price dropped',
|
||||
}
|
||||
|
||||
def test_thinking_tag_variant_is_stripped(self):
|
||||
raw = (
|
||||
'<thinking>weighing {"important": false, "summary": "no"}</thinking>\n'
|
||||
'{"important": true, "summary": "Price dropped"}'
|
||||
)
|
||||
assert parse_eval_response(raw)['important'] is True
|
||||
|
||||
def test_multiple_reasoning_blocks_are_stripped(self):
|
||||
raw = (
|
||||
'<think>step one</think>'
|
||||
'<think>{"important": false, "summary": "no"}</think>'
|
||||
'{"important": true, "summary": "Price dropped"}'
|
||||
)
|
||||
assert parse_eval_response(raw)['important'] is True
|
||||
|
||||
def test_unterminated_reasoning_block_raises(self):
|
||||
# Truncated by max_tokens mid-thought: the only JSON present is the abandoned guess,
|
||||
# so returning it would silently invert the verdict. Raise instead and let
|
||||
# evaluator.py's handler fall back to "important" rather than dropping the change.
|
||||
raw = (
|
||||
'<think>\n'
|
||||
'First guess: {"important": false, "summary": "nothing"}\n'
|
||||
'But actually the price dropped, so'
|
||||
)
|
||||
with pytest.raises(ValueError, match='unterminated reasoning block'):
|
||||
_extract_json(raw)
|
||||
|
||||
def test_unterminated_block_propagates_out_of_parse_eval_response(self):
|
||||
"""Deliberately NOT swallowed. parse_eval_response's own fallback is
|
||||
important=False, which suppresses the notification - the opposite of what
|
||||
evaluator.py wants on failure ("don't suppress the notification"). Letting
|
||||
ValueError escape routes it to that handler instead. Do not add ValueError to
|
||||
the except tuple in parse_eval_response."""
|
||||
raw = '<think>truncated mid-thought {"important": false}'
|
||||
with pytest.raises(ValueError):
|
||||
parse_eval_response(raw)
|
||||
|
||||
def test_response_with_no_reasoning_block_is_untouched(self):
|
||||
raw = '{"important": true, "summary": "plain"}'
|
||||
assert _extract_json(raw) == raw
|
||||
|
||||
|
||||
class TestParseEvalResponse:
|
||||
def test_valid_important_true(self):
|
||||
@@ -51,12 +134,36 @@ class TestParseEvalResponse:
|
||||
assert result['important'] is False
|
||||
assert 'date counter' in result['summary']
|
||||
|
||||
def test_string_false_evaluates_to_false(self):
|
||||
raw = '{"important": "false", "summary": "No relevant changes found"}'
|
||||
result = parse_eval_response(raw)
|
||||
assert result['important'] is False
|
||||
assert result['summary'] == 'No relevant changes found'
|
||||
|
||||
def test_string_true_evaluates_to_true(self):
|
||||
raw = '{"important": "true", "summary": "Price updated"}'
|
||||
result = parse_eval_response(raw)
|
||||
assert result['important'] is True
|
||||
assert result['summary'] == 'Price updated'
|
||||
|
||||
def test_markdown_fenced_response(self):
|
||||
raw = '```json\n{"important": true, "summary": "New job posted"}\n```'
|
||||
result = parse_eval_response(raw)
|
||||
assert result['important'] is True
|
||||
assert result['summary'] == 'New job posted'
|
||||
|
||||
def test_reasoning_model_response_parsed_correctly(self):
|
||||
raw = (
|
||||
'<think>\n'
|
||||
'1. Checking diff: {"important": false} was our initial thought.\n'
|
||||
'2. However the price dropped from $100 to $80.\n'
|
||||
'</think>\n'
|
||||
'{"important": true, "summary": "Price dropped by $20"}'
|
||||
)
|
||||
result = parse_eval_response(raw)
|
||||
assert result['important'] is True
|
||||
assert result['summary'] == 'Price dropped by $20'
|
||||
|
||||
def test_malformed_json_falls_back_to_safe_default(self):
|
||||
result = parse_eval_response('this is not json at all')
|
||||
assert result['important'] is False
|
||||
@@ -71,6 +178,11 @@ class TestParseEvalResponse:
|
||||
result = parse_eval_response(raw)
|
||||
assert result['important'] is True
|
||||
|
||||
def test_falsy_integer_coerced_to_bool(self):
|
||||
raw = '{"important": 0, "summary": "no"}'
|
||||
result = parse_eval_response(raw)
|
||||
assert result['important'] is False
|
||||
|
||||
def test_summary_stripped_of_whitespace(self):
|
||||
raw = '{"important": false, "summary": " no match "}'
|
||||
result = parse_eval_response(raw)
|
||||
@@ -88,6 +200,32 @@ class TestParseEvalResponse:
|
||||
assert result['summary'] == 'skip'
|
||||
|
||||
|
||||
class TestParsePreviewResponse:
|
||||
def test_valid_found_true(self):
|
||||
raw = '{"found": true, "answer": "Price is $49.99"}'
|
||||
result = parse_preview_response(raw)
|
||||
assert result['found'] is True
|
||||
assert result['answer'] == 'Price is $49.99'
|
||||
|
||||
def test_valid_found_false(self):
|
||||
raw = '{"found": false, "answer": "Item not listed"}'
|
||||
result = parse_preview_response(raw)
|
||||
assert result['found'] is False
|
||||
assert result['answer'] == 'Item not listed'
|
||||
|
||||
def test_string_false_in_preview(self):
|
||||
raw = '{"found": "false", "answer": "Not found"}'
|
||||
result = parse_preview_response(raw)
|
||||
assert result['found'] is False
|
||||
assert result['answer'] == 'Not found'
|
||||
|
||||
def test_preview_with_think_tags(self):
|
||||
raw = '<think>Looking for price...</think>\n{"found": true, "answer": "$19.99"}'
|
||||
result = parse_preview_response(raw)
|
||||
assert result['found'] is True
|
||||
assert result['answer'] == '$19.99'
|
||||
|
||||
|
||||
class TestParseSetupResponse:
|
||||
def test_no_prefilter_needed(self):
|
||||
raw = '{"needs_prefilter": false, "selector": null, "reason": "intent is global"}'
|
||||
@@ -95,8 +233,15 @@ class TestParseSetupResponse:
|
||||
assert result['needs_prefilter'] is False
|
||||
assert result['selector'] is None
|
||||
|
||||
def test_string_false_in_setup(self):
|
||||
raw = '{"needs_prefilter": "false", "selector": null, "reason": "global"}'
|
||||
result = parse_setup_response(raw)
|
||||
assert result['needs_prefilter'] is False
|
||||
|
||||
def test_semantic_selector_accepted(self):
|
||||
raw = '{"needs_prefilter": true, "selector": "footer", "reason": "intent references footer"}'
|
||||
raw = (
|
||||
'{"needs_prefilter": true, "selector": "footer", "reason": "intent references footer"}'
|
||||
)
|
||||
result = parse_setup_response(raw)
|
||||
assert result['needs_prefilter'] is True
|
||||
assert result['selector'] == 'footer'
|
||||
|
||||
@@ -84,10 +84,12 @@ def test_socks5(client, live_server, measure_memory_usage, datastore_path):
|
||||
# PROXY CHECKER WIDGET CHECK - this needs more checking
|
||||
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
|
||||
|
||||
res = client.get(
|
||||
# POST only - it kicks off real fetches through every configured proxy
|
||||
res = client.post(
|
||||
url_for("check_proxies.start_check", uuid=uuid),
|
||||
follow_redirects=True
|
||||
)
|
||||
assert res.status_code == 200
|
||||
# It's probably already finished super fast :(
|
||||
#assert b"RUNNING" in res.data
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
res = c.get(url_for("logout"),
|
||||
res = c.post(url_for("logout"),
|
||||
follow_redirects=True)
|
||||
|
||||
assert b"Login" in res.data
|
||||
|
||||
@@ -79,26 +79,41 @@ def test_snapshot_refuses_browser_that_cannot_preview(client, live_server, measu
|
||||
from changedetectionio.blueprint.add_watch_ui import browser_config
|
||||
monkeypatch.setattr(browser_config, 'is_visual_capable', lambda name, datastore: False)
|
||||
|
||||
snapshot_url = url_for('add_watch_ui.add_watch_ui_snapshot')
|
||||
|
||||
# Nothing capable, and no explicit browser asked for -> nothing to preview with
|
||||
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com'))
|
||||
res = client.post(snapshot_url, data={'url': 'https://example.com'})
|
||||
assert res.status_code == 400
|
||||
assert b'No interactive browser' in res.data
|
||||
|
||||
# Explicitly asking for a browser that can't preview is refused just the same
|
||||
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com',
|
||||
fetch_backend='html_requests'))
|
||||
res = client.post(snapshot_url, data={'url': 'https://example.com',
|
||||
'fetch_backend': 'html_requests'})
|
||||
assert res.status_code == 400
|
||||
|
||||
# A made-up name never resolves to a capable fetcher either (real capability lookup here)
|
||||
monkeypatch.undo()
|
||||
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com',
|
||||
fetch_backend='../../etc/passwd'))
|
||||
res = client.post(snapshot_url, data={'url': 'https://example.com',
|
||||
'fetch_backend': '../../etc/passwd'})
|
||||
assert res.status_code == 400
|
||||
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot', url='https://example.com',
|
||||
fetch_backend='os'))
|
||||
res = client.post(snapshot_url, data={'url': 'https://example.com',
|
||||
'fetch_backend': 'os'})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_snapshot_is_post_only(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""A GET must not reach the endpoint at all.
|
||||
|
||||
/snapshot drives a real server-side browser fetch and hands the rendered result back in
|
||||
the response (GHSA-56fq-63vj-9992). As a GET that is reachable by anything that can make
|
||||
the operator's browser issue a request - an <img>/<iframe>/link from another site - with
|
||||
no CSRF token in play. POST-only + CSRFProtect means only our own page can trigger it.
|
||||
"""
|
||||
# Method mismatch surfaces as 404 here rather than 405
|
||||
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot') + '?url=https://example.com')
|
||||
assert res.status_code in (404, 405)
|
||||
|
||||
|
||||
def test_submit_rejects_unknown_fetcher(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""A posted browser is checked server side, so a doctored form can't pin a junk fetcher."""
|
||||
datastore = _datastore(client)
|
||||
|
||||
@@ -321,3 +321,80 @@ def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path
|
||||
date_created = res.json.get('date_created')
|
||||
assert date_created != 454444444444, "ReadOnly date_created should not be updateable"
|
||||
assert date_created != "454444444444", "ReadOnly date_created should not be updateable"
|
||||
|
||||
|
||||
def test_api_watch_tag_field_accepts_names_and_uuids(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""The `tag` field on a watch takes tag *names*, `tags` takes UUIDs.
|
||||
|
||||
`tag` was documented as taking a UUID for years while the code fed it to add_tag(title),
|
||||
so a UUID silently created a junk group *titled* with that UUID and never applied the tag
|
||||
the caller asked for. Both spellings now resolve to the same tag.
|
||||
"""
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
hdr = {'x-api-key': api_key, 'content-type': 'application/json'}
|
||||
|
||||
def titles_of(watch_uuid):
|
||||
tags = datastore.data['settings']['application']['tags']
|
||||
return sorted(tags[t].get('title') for t in datastore.data['watching'][watch_uuid].get('tags'))
|
||||
|
||||
# A name creates the group
|
||||
res = client.post(url_for("createwatch"), data=json.dumps({"url": test_url, "tag": "helloworld"}), headers=hdr)
|
||||
assert res.status_code == 201
|
||||
assert titles_of(res.json['uuid']) == ['helloworld']
|
||||
|
||||
# An existing tag's UUID links to that tag rather than making a group named after the UUID
|
||||
res = client.post(url_for("tag"), data=json.dumps({"title": "RealTag"}), headers=hdr)
|
||||
assert res.status_code == 201
|
||||
real_tag_uuid = res.json['uuid']
|
||||
tag_count_before = len(datastore.data['settings']['application']['tags'])
|
||||
|
||||
res = client.post(url_for("createwatch"), data=json.dumps({"url": f"{test_url}?p=2", "tag": real_tag_uuid}), headers=hdr)
|
||||
assert res.status_code == 201
|
||||
assert real_tag_uuid in datastore.data['watching'][res.json['uuid']].get('tags')
|
||||
assert titles_of(res.json['uuid']) == ['RealTag']
|
||||
assert len(datastore.data['settings']['application']['tags']) == tag_count_before, "No junk tag titled with a UUID"
|
||||
|
||||
# `tags` with UUIDs keeps working
|
||||
res = client.post(url_for("createwatch"), data=json.dumps({"url": f"{test_url}?p=3", "tags": [real_tag_uuid]}), headers=hdr)
|
||||
assert res.status_code == 201
|
||||
assert titles_of(res.json['uuid']) == ['RealTag']
|
||||
|
||||
# Names and UUIDs can be mixed, and blank entries from a trailing comma are dropped -
|
||||
# add_tag() returns False for those and a falsy entry breaks every watch['tags'] lookup
|
||||
res = client.post(url_for("createwatch"),
|
||||
data=json.dumps({"url": f"{test_url}?p=4", "tag": f"Mixed,,{real_tag_uuid},"}), headers=hdr)
|
||||
assert res.status_code == 201
|
||||
assert titles_of(res.json['uuid']) == ['Mixed', 'RealTag']
|
||||
assert all(datastore.data['watching'][res.json['uuid']].get('tags')), "No falsy entries in tags"
|
||||
|
||||
# A UUID that matches no tag is skipped rather than becoming a group named after it
|
||||
unknown_uuid = '0be0272a-19dc-4c97-8aae-5a68df319489'
|
||||
tag_count_before = len(datastore.data['settings']['application']['tags'])
|
||||
res = client.post(url_for("createwatch"),
|
||||
data=json.dumps({"url": f"{test_url}?p=5", "tag": unknown_uuid}), headers=hdr)
|
||||
assert res.status_code == 201
|
||||
assert datastore.data['watching'][res.json['uuid']].get('tags') == []
|
||||
assert len(datastore.data['settings']['application']['tags']) == tag_count_before
|
||||
|
||||
# Names are matched case-insensitively against existing tags, as the spec claims
|
||||
tag_count_before = len(datastore.data['settings']['application']['tags'])
|
||||
res = client.post(url_for("createwatch"),
|
||||
data=json.dumps({"url": f"{test_url}?p=6", "tag": "rEaLtAg"}), headers=hdr)
|
||||
assert res.status_code == 201
|
||||
assert datastore.data['watching'][res.json['uuid']].get('tags') == [real_tag_uuid]
|
||||
assert len(datastore.data['settings']['application']['tags']) == tag_count_before, "Casing must not fork a second tag"
|
||||
|
||||
# `tags` is applied verbatim and never creates: an unknown UUID is stored as a dangling
|
||||
# reference that simply resolves to no group. Documented, and harmless because the lookup
|
||||
# is a dictfilt() over known tags - pinned here so changing it has to be deliberate.
|
||||
bogus = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
tag_count_before = len(datastore.data['settings']['application']['tags'])
|
||||
res = client.post(url_for("createwatch"),
|
||||
data=json.dumps({"url": f"{test_url}?p=7", "tags": [bogus]}), headers=hdr)
|
||||
assert res.status_code == 201
|
||||
assert datastore.data['watching'][res.json['uuid']].get('tags') == [bogus]
|
||||
assert len(datastore.data['settings']['application']['tags']) == tag_count_before
|
||||
assert datastore.get_all_tags_for_watch(res.json['uuid']) == {}
|
||||
assert client.get(url_for("watchlist.index")).status_code == 200, "A dangling tag ref must not break the list"
|
||||
|
||||
@@ -96,7 +96,7 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage
|
||||
assert b'ldjson-price-track-offer' in res.data
|
||||
|
||||
# Accept it
|
||||
client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True))
|
||||
client.post(url_for('price_data_follower.accept', uuid=uuid), follow_redirects=True)
|
||||
client.post(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
# Offer should be gone
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_backup(client, live_server, measure_memory_usage, datastore_path):
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Launch the thread in the background to create the backup
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("backups.request_backup"),
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -136,7 +136,7 @@ def test_backup_restore(client, live_server, measure_memory_usage, datastore_pat
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Create a full backup
|
||||
client.get(url_for("backups.request_backup"), follow_redirects=True)
|
||||
client.post(url_for("backups.request_backup"), follow_redirects=True)
|
||||
time.sleep(4)
|
||||
|
||||
# Download the latest backup zip
|
||||
|
||||
@@ -493,7 +493,7 @@ def test_tag_mute_persists(client, live_server):
|
||||
tag_uuid = datastore.add_tag('Test Tag')
|
||||
|
||||
# Mute the tag
|
||||
response = client.get(url_for("tags.mute", uuid=tag_uuid))
|
||||
response = client.post(url_for("tags.mute", uuid=tag_uuid))
|
||||
assert response.status_code == 302 # Redirect
|
||||
|
||||
# Verify muted in memory
|
||||
|
||||
@@ -11,7 +11,7 @@ def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
|
||||
# Be sure we got a session cookie
|
||||
res = client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="zh_Hant_TW"), # Traditional
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -21,7 +21,7 @@ def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
|
||||
assert '選擇語言'.encode() in res.data
|
||||
|
||||
# Check second set works
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="en_GB"),
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -30,7 +30,7 @@ def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
|
||||
assert b"Select Language" in res.data, "Second set of language worked"
|
||||
|
||||
# Check arbitration between zh_Hant_TW<->zh
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="zh"), # Simplified chinese
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -89,7 +89,7 @@ def test_language_switching(client, live_server, measure_memory_usage, datastore
|
||||
client.get(url_for("add_watch_ui.add_watch_ui_index"), follow_redirects=True)
|
||||
|
||||
# Step 1: Set the language to Italian using the /set-language endpoint
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="it"),
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -119,7 +119,7 @@ def test_language_switching(client, live_server, measure_memory_usage, datastore
|
||||
# NB: use 'en_GB' not 'en' — only the variants are in language_codes; the
|
||||
# plain 'en' code is silently rejected by set_language and the locale would
|
||||
# remain at 'it', defeating the round-trip assertion below.
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="en_GB"),
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -152,7 +152,7 @@ def test_invalid_locale(client, live_server, measure_memory_usage, datastore_pat
|
||||
# bare 'en' is NOT in language_codes and is silently rejected by
|
||||
# set_language, so passing it here would leave the session locale unset
|
||||
# and let the (unrelated) Accept-Language fallback decide what renders.
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="en_GB"),
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -160,7 +160,7 @@ def test_invalid_locale(client, live_server, measure_memory_usage, datastore_pat
|
||||
assert res.status_code == 200
|
||||
|
||||
# Try to set an invalid locale
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="invalid_locale_xyz"),
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -190,7 +190,7 @@ def test_language_persistence_in_session(client, live_server, measure_memory_usa
|
||||
client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
|
||||
# Set language to Italian
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="it"),
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -215,7 +215,7 @@ def test_language_persistence_in_session(client, live_server, measure_memory_usa
|
||||
assert sess.get('locale') == 'it', "Locale should be set in session"
|
||||
|
||||
# Call auto-detect to clear the locale
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("ui.delete_locale_language_session_var_if_it_exists"),
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -254,7 +254,7 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
|
||||
client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
|
||||
# Set language with a redirect parameter (simulating language change from /settings)
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="de", redirect="/settings"),
|
||||
follow_redirects=False
|
||||
)
|
||||
@@ -268,7 +268,7 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
|
||||
assert sess.get('locale') == 'de'
|
||||
|
||||
# Test with invalid locale (should still redirect safely)
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="invalid_locale", redirect="/settings"),
|
||||
follow_redirects=False
|
||||
)
|
||||
@@ -276,7 +276,7 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
|
||||
assert '/settings' in res.location
|
||||
|
||||
# Test with malicious redirect (should default to watchlist)
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="en", redirect="https://evil.com"),
|
||||
follow_redirects=False
|
||||
)
|
||||
@@ -296,7 +296,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
|
||||
client.get(url_for("watchlist.index"), follow_redirects=True)
|
||||
|
||||
# Test Italian translations
|
||||
res = client.get(url_for("set_language", locale="it"), follow_redirects=True)
|
||||
res = client.post(url_for("set_language", locale="it"), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
|
||||
@@ -312,7 +312,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
|
||||
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
|
||||
|
||||
# Test Korean translations
|
||||
res = client.get(url_for("set_language", locale="ko"), follow_redirects=True)
|
||||
res = client.post(url_for("set_language", locale="ko"), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
|
||||
@@ -332,7 +332,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
|
||||
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
|
||||
|
||||
# Test Chinese Simplified translations
|
||||
res = client.get(url_for("set_language", locale="zh"), follow_redirects=True)
|
||||
res = client.post(url_for("set_language", locale="zh"), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
|
||||
@@ -348,7 +348,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
|
||||
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
|
||||
|
||||
# Test German translations
|
||||
res = client.get(url_for("set_language", locale="de"), follow_redirects=True)
|
||||
res = client.post(url_for("set_language", locale="de"), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
|
||||
@@ -363,7 +363,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
|
||||
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
|
||||
|
||||
# Test Russian translations
|
||||
res = client.get(url_for("set_language", locale="ru"), follow_redirects=True)
|
||||
res = client.post(url_for("set_language", locale="ru"), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
|
||||
@@ -378,7 +378,7 @@ def test_time_unit_translations(client, live_server, measure_memory_usage, datas
|
||||
assert b"Time Between Check" not in res.data, "Should not have English 'Time Between Check'"
|
||||
|
||||
# Test Traditional Chinese (zh_Hant_TW) translations
|
||||
res = client.get(url_for("set_language", locale="zh_Hant_TW"), follow_redirects=True)
|
||||
res = client.post(url_for("set_language", locale="zh_Hant_TW"), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.get(url_for("settings.settings_page"), follow_redirects=True)
|
||||
@@ -627,7 +627,7 @@ def test_session_locale_overrides_accept_language(client, live_server, measure_m
|
||||
"Expected Taiwan flag 'fi fi-tw' from auto-detect"
|
||||
|
||||
# Step 2: User explicitly selects Korean language
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="ko"),
|
||||
headers={'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8'}, # Browser still sends zh-TW
|
||||
follow_redirects=True
|
||||
@@ -700,7 +700,7 @@ def test_clear_history_translated_confirmation(client, live_server, measure_memo
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Set language to German
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="de"),
|
||||
follow_redirects=True
|
||||
)
|
||||
@@ -726,7 +726,7 @@ def test_clear_history_translated_confirmation(client, live_server, measure_memo
|
||||
"German confirmation word 'loschen' should be accepted (issue #3865)"
|
||||
|
||||
# Switch back to English and verify English word still works
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("set_language", locale="en_US"),
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_timezone(mocker):
|
||||
|
||||
timezone = 'America/Buenos_Aires'
|
||||
currentDate = arrow.now(timezone)
|
||||
arrowNowMock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now")
|
||||
arrowNowMock = mocker.patch("arrow.now")
|
||||
arrowNowMock.return_value = currentDate
|
||||
finalRender = render(f"{{% now '{timezone}' %}}")
|
||||
|
||||
@@ -115,7 +115,7 @@ def test_add_weekday(mocker):
|
||||
|
||||
timezone = 'utc'
|
||||
currentDate = arrow.now(timezone)
|
||||
arrowNowMock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now")
|
||||
arrowNowMock = mocker.patch("arrow.now")
|
||||
arrowNowMock.return_value = currentDate
|
||||
finalRender = render(f"{{% now '{timezone}' + 'weekday=1' %}}")
|
||||
|
||||
|
||||
@@ -158,6 +158,35 @@ def test_lone_surrogate_escapes_do_not_break_filters():
|
||||
assert '\U0001F600' in text
|
||||
text.encode('utf-8')
|
||||
|
||||
# The surrogate can just as easily land in an object *key*, which has to be sanitized too -
|
||||
# otherwise the whole-document re-serialization still trips over it
|
||||
lone_key = '{"ti' + BS + 'uD800tle": "value", "other": "untouched"}'
|
||||
for f in ("json:$.other", "jq:.other", "jqraw:.other") if jq_support else ("json:$.other",):
|
||||
text = html_tools.extract_json_as_string(lone_key, f)
|
||||
assert "untouched" in text
|
||||
text.encode('utf-8')
|
||||
|
||||
# Dumping the whole document exercises the key path directly
|
||||
for f in ("json:$", "jq:.") if jq_support else ("json:$",):
|
||||
text = html_tools.extract_json_as_string(lone_key, f)
|
||||
assert "value" in text
|
||||
assert not any(0xD800 <= ord(c) <= 0xDFFF for c in text)
|
||||
text.encode('utf-8')
|
||||
|
||||
# Nested arrays and non-string scalars have to be walked too. The scalars come first
|
||||
# deliberately: detection short-circuits on the first surrogate it finds, so anything after
|
||||
# the offending value would never be visited.
|
||||
nested = ('{"count": 3, "flag": null, "ok": true, '
|
||||
'"items": ["clean", "bad' + BS + 'uD800", [1, "deep' + BS + 'uDC00"]]}')
|
||||
for f in ("json:$", "jq:.") if jq_support else ("json:$",):
|
||||
text = html_tools.extract_json_as_string(nested, f)
|
||||
assert "clean" in text and "deep" in text
|
||||
assert not any(0xD800 <= ord(c) <= 0xDFFF for c in text)
|
||||
text.encode('utf-8')
|
||||
|
||||
# ...and the scalars must survive the round-trip unmangled
|
||||
assert html_tools.extract_json_as_string(nested, "json:$.count").strip() == "3"
|
||||
|
||||
|
||||
def test_unittest_inline_extract_body():
|
||||
content = """
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_language_endpoints_work_for_anonymous_users(client, live_server, measur
|
||||
follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
client.get(url_for("logout"), follow_redirects=True)
|
||||
client.post(url_for("logout"), follow_redirects=True)
|
||||
|
||||
# Both language links are rendered on the login page, so both must be reachable
|
||||
res = client.get(url_for("login"))
|
||||
@@ -33,13 +33,13 @@ def test_language_endpoints_work_for_anonymous_users(client, live_server, measur
|
||||
assert b'language-selector' in res.data, "Language modal trigger should render for anonymous users"
|
||||
|
||||
# Picking a specific language must not redirect to the login page
|
||||
res = client.get(url_for("set_language", locale="de"), follow_redirects=False)
|
||||
res = client.post(url_for("set_language", locale="de"), follow_redirects=False)
|
||||
assert res.status_code == 302
|
||||
assert '/login' not in res.headers.get("Location", ""), \
|
||||
"set_language must not bounce anonymous users to /login"
|
||||
|
||||
# ...and neither must clearing it back to auto-detect
|
||||
res = client.get(url_for("ui.delete_locale_language_session_var_if_it_exists"), follow_redirects=False)
|
||||
res = client.post(url_for("ui.delete_locale_language_session_var_if_it_exists"), follow_redirects=False)
|
||||
assert res.status_code == 302
|
||||
assert '/login' not in res.headers.get("Location", ""), \
|
||||
"Auto-detect must not bounce anonymous users to /login (it renders on the login page)"
|
||||
|
||||
@@ -393,12 +393,12 @@ def test_llm_models_endpoint_blocks_private_api_base(
|
||||
|
||||
def test_llm_test_endpoint_blocks_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""GET /settings/llm/test must refuse api_base pointing at private/loopback
|
||||
"""POST /settings/llm/test must refuse api_base pointing at private/loopback
|
||||
hosts and must never reach litellm.completion()."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
for bad in _SSRF_PRIVATE_HOSTS:
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for('settings.llm.llm_test'),
|
||||
query_string={'model': 'openai/gpt-4', 'api_base': bad},
|
||||
)
|
||||
@@ -530,7 +530,7 @@ def test_llm_test_refuses_to_leak_stored_key_to_different_api_base(
|
||||
monkeypatch.setattr(llm_client, 'completion',
|
||||
lambda **kw: calls.append(kw) or ('', 0, 0, 0))
|
||||
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for('settings.llm.llm_test'),
|
||||
query_string={
|
||||
'model': 'gpt-4o-mini',
|
||||
|
||||
@@ -75,9 +75,10 @@ def test_llm_change_summary_cascades_from_tag(
|
||||
_set_response(datastore_path, HTML_V1)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
# Create a tag with llm_change_summary
|
||||
# Create a tag with llm_change_summary, AI set to On so its watches inherit it
|
||||
tag_uuid = ds.add_tag('events-group')
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Summarise new events'
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
|
||||
|
||||
# Watch in that tag, no own summary prompt
|
||||
uuid = ds.add_watch(url=test_url)
|
||||
@@ -281,6 +282,7 @@ def test_tag_prompt_overrides_global_default(
|
||||
|
||||
tag_uuid = ds.add_tag('my-group')
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Tag: bullet points.'
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
|
||||
|
||||
uuid = ds.add_watch(url='https://example.com')
|
||||
watch = ds.data['watching'][uuid]
|
||||
@@ -306,6 +308,7 @@ def test_watch_prompt_overrides_tag_and_global(
|
||||
|
||||
tag_uuid = ds.add_tag('my-group')
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Tag prompt.'
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
|
||||
|
||||
uuid = ds.add_watch(url='https://example.com')
|
||||
watch = ds.data['watching'][uuid]
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for group/tag LLM field overrides on the watch edit page.
|
||||
Tests for the AI/LLM settings a group hands to its watches.
|
||||
|
||||
When a watch's first linked tag has llm_intent or llm_change_summary set
|
||||
and the watch itself has no own value, the watch edit form should render
|
||||
the relevant textarea as readonly with a "From group '<name>': <value>"
|
||||
placeholder.
|
||||
A group has exactly ONE AI control on its edit page — the ternary llm_backend_profile
|
||||
("AI for watches in this group"):
|
||||
|
||||
When the watch has its own value, the textarea is editable as normal.
|
||||
On the group's llm_intent / llm_change_summary apply to every watch in
|
||||
it (unless the watch fills in its own), and AI is on for all of them
|
||||
Off AI off for every watch in the group; its prompts are never used
|
||||
Leave it to each the group has no say; each watch's own AI settings apply
|
||||
|
||||
The evaluator cascade (resolve_llm_field) is already tested in the
|
||||
evaluator unit tests; these tests focus on the UI and form behaviour.
|
||||
Only "On" makes anything cascade, so it decides both:
|
||||
|
||||
* whether the evaluator inherits the group's prompts (resolve_llm_field /
|
||||
get_effective_summary_prompt), and
|
||||
* whether the watch edit page shows the inherited value as a
|
||||
"From group '<name>': <value>" placeholder.
|
||||
|
||||
So every UI assertion here is paired: group On → "From group ..." visible, group Off or
|
||||
undecided → not a trace of it. On a watch the same field is a plain on/off checkbox (#4204).
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
|
||||
from flask import url_for
|
||||
@@ -20,6 +29,42 @@ from flask import url_for
|
||||
from changedetectionio.tests.util import live_server_setup, delete_all_watches
|
||||
|
||||
|
||||
# The exact rendered string under test, from templates/edit/include_llm_intent.html:
|
||||
# {% set intent_placeholder = _("From group '%(name)s': %(value)s", ...) %}
|
||||
def _from_group_text(name, value):
|
||||
return f"From group '{name}': {value}"
|
||||
|
||||
|
||||
def _page_text(res):
|
||||
"""Response body with HTML entities resolved, so we can match the placeholder as written."""
|
||||
return html.unescape(res.data.decode('utf-8', errors='replace'))
|
||||
|
||||
|
||||
def _input_tags(body, name):
|
||||
"""Every whole <input ...> tag carrying name="<name>", in document order."""
|
||||
tags = []
|
||||
pos = body.find(f'name="{name}"')
|
||||
while pos != -1:
|
||||
start = body.rfind('<input', 0, pos)
|
||||
end = body.find('>', pos)
|
||||
tags.append(body[start:end + 1])
|
||||
pos = body.find(f'name="{name}"', end)
|
||||
return tags
|
||||
|
||||
|
||||
def _input_tag(body, name):
|
||||
"""Return the first <input ...> tag carrying name="<name>", or '' if there isn't one."""
|
||||
tags = _input_tags(body, name)
|
||||
return tags[0] if tags else ''
|
||||
|
||||
|
||||
def _checkbox_is_checked(body, name):
|
||||
"""True when that checkbox renders as checked (attribute order is not guaranteed)."""
|
||||
tag = _input_tag(body, name)
|
||||
assert tag, f'no <input name="{name}"> in the page'
|
||||
return 'checked' in tag
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -51,14 +96,20 @@ def _api_token(client):
|
||||
# Tag setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _add_tag_with_llm(datastore, title, llm_intent='', llm_change_summary=''):
|
||||
"""Create a tag with LLM fields set directly in the datastore."""
|
||||
def _add_tag_with_llm(datastore, title, llm_intent='', llm_change_summary='', ai=True):
|
||||
"""Create a tag with LLM fields set directly in the datastore.
|
||||
|
||||
`ai` is the group's single AI control (llm_backend_profile): True = On, False = Off,
|
||||
None = leave it to each watch. Defaults to True because most cases here are about what
|
||||
a group set to "On" hands down.
|
||||
"""
|
||||
tag_uuid = datastore.add_tag(title)
|
||||
tag = datastore.data['settings']['application']['tags'][tag_uuid]
|
||||
if llm_intent:
|
||||
tag['llm_intent'] = llm_intent
|
||||
if llm_change_summary:
|
||||
tag['llm_change_summary'] = llm_change_summary
|
||||
tag['llm_backend_profile'] = ai
|
||||
return tag_uuid
|
||||
|
||||
|
||||
@@ -72,16 +123,123 @@ def _link_watch_to_tag(datastore, watch_uuid, tag_uuid):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Watch edit page — llm_intent group override
|
||||
# The group's one AI control — ternary on a group, checkbox on a watch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_watch_edit_shows_llm_intent_placeholder_from_group(
|
||||
def test_group_edit_page_has_the_ternary_ai_control(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
When a watch has no own llm_intent but its first tag does,
|
||||
the edit page must show "From group" + group name + group value in the
|
||||
placeholder so the user sees the inherited value but can still type to override.
|
||||
The field must NOT be readonly.
|
||||
The group edit page must offer all three states — without them there is no way to turn
|
||||
group-wide AI settings on, or to switch AI off for a whole group (#4204).
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
|
||||
tag_uuid = ds.add_tag('Ternary Group')
|
||||
|
||||
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
text = _page_text(res)
|
||||
|
||||
assert 'name="llm_backend_profile"' in body, \
|
||||
"group edit page is missing the 'AI for watches in this group' control"
|
||||
for value in ('true', 'false', 'none'):
|
||||
assert f'name="llm_backend_profile" value="{value}"' in body, \
|
||||
f"group AI control is missing its '{value}' option"
|
||||
assert 'AI for watches in this group' in text
|
||||
assert 'Leave it to each watch' in text
|
||||
# New groups start undecided, so they never touch their watches
|
||||
assert 'id="llm_backend_profile_none" checked' in body, \
|
||||
"a new group must default to 'Leave it to each watch'"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_watch_edit_page_has_a_plain_ai_checkbox(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""A watch gets a simple on/off, not the group's three-way choice."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
|
||||
assert 'name="llm_intent"' in body # AI section is rendered...
|
||||
assert 'type="checkbox"' in _input_tag(body, 'llm_backend_profile')
|
||||
assert 'Leave it to each watch' not in _page_text(res), \
|
||||
"the group's three-way AI choice must not appear on a watch"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_group_edit_page_never_shows_the_from_group_placeholder(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
"From group ..." describes something inherited by a watch; the group edit page is
|
||||
where the value is authored, so it must show group-flavoured copy instead.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Authoring Group', llm_intent='Group intent value')
|
||||
|
||||
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
|
||||
assert res.status_code == 200
|
||||
text = _page_text(res)
|
||||
|
||||
assert 'From group' not in text
|
||||
# Group copy, not the per-watch copy (both live in the same shared include)
|
||||
assert 'Set a change intent for all watches in this tag/group' in text
|
||||
assert 'Describe what you care about' not in text
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_group_edit_form_saves_and_reloads_each_ai_state(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""All three states round-trip through the real form, and the prompt is kept regardless."""
|
||||
res = client.post(url_for('tags.form_tag_add'), data={'name': 'Saved Group'}, follow_redirects=True)
|
||||
assert b'Tag added' in res.data
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
tag_uuid = list(ds.data['settings']['application']['tags'].keys())[0]
|
||||
|
||||
for posted, expected in (('true', True), ('false', False), ('none', None)):
|
||||
res = client.post(
|
||||
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
|
||||
data={'title': 'Saved Group',
|
||||
'llm_intent': 'Only notify me about price drops',
|
||||
'llm_backend_profile': posted},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Updated' in res.data
|
||||
tag = ds.data['settings']['application']['tags'][tag_uuid]
|
||||
assert tag.get('llm_backend_profile') is expected, f"posting {posted!r} should store {expected!r}"
|
||||
# The prompt is always kept — the AI state only decides whether it is used
|
||||
assert tag.get('llm_intent') == 'Only notify me about price drops'
|
||||
|
||||
# ..and the reloaded page comes back on the same option
|
||||
body = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid)).data.decode('utf-8', errors='replace')
|
||||
assert f'id="llm_backend_profile_{posted}" checked' in body, \
|
||||
f"saved state {posted!r} must render as the selected option"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Watch edit page — llm_intent group override, gated on the checkbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_watch_edit_shows_llm_intent_placeholder_when_group_overrides_enabled(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Group override ON + watch has no own llm_intent → the edit page shows
|
||||
"From group '<name>': <value>" as the placeholder, and the field stays editable.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
@@ -89,34 +247,64 @@ def test_watch_edit_shows_llm_intent_placeholder_from_group(
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Price Watchers', llm_intent='Notify only when price drops')
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Price Watchers',
|
||||
llm_intent='Notify only when price drops',
|
||||
ai=True)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
text = _page_text(res)
|
||||
|
||||
assert 'name="llm_intent"' in body
|
||||
|
||||
# Placeholder must contain "From group", the tag name, and the value
|
||||
assert 'From group' in body
|
||||
assert 'Price Watchers' in body
|
||||
assert 'Notify only when price drops' in body
|
||||
assert 'name="llm_intent"' in text
|
||||
assert _from_group_text('Price Watchers', 'Notify only when price drops') in text, \
|
||||
"watch edit must show the inherited group intent as a 'From group ...' placeholder"
|
||||
|
||||
# Field must be editable — no readonly attribute
|
||||
intent_pos = body.find('name="llm_intent"')
|
||||
snippet = body[max(0, intent_pos - 50): intent_pos + 300]
|
||||
intent_pos = text.find('name="llm_intent"')
|
||||
snippet = text[max(0, intent_pos - 50): intent_pos + 300]
|
||||
assert 'readonly' not in snippet, \
|
||||
f"llm_intent must be editable when group sets it; snippet: {snippet!r}"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_watch_edit_hides_llm_intent_placeholder_when_group_overrides_disabled(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Same group, same intent, checkbox OFF → no "From group ..." anywhere, and the
|
||||
generic example placeholder is used instead. This is the pairing that makes the
|
||||
checkbox meaningful.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Price Watchers',
|
||||
llm_intent='Notify only when price drops',
|
||||
ai=False)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert res.status_code == 200
|
||||
text = _page_text(res)
|
||||
|
||||
assert 'From group' not in text, \
|
||||
"group AI settings must not leak into the watch unless the group is set to On"
|
||||
assert 'Notify only when price drops' not in text
|
||||
# Falls back to the normal per-watch example placeholder
|
||||
assert 'e.g. Alert me when the price drops below $300' in text
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_watch_edit_llm_intent_shows_own_value_not_group_placeholder(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
When a watch has its own llm_intent, the textarea body shows the watch's value
|
||||
and the placeholder does NOT say "From group" (the group value is irrelevant).
|
||||
When the watch has its own llm_intent, the textarea body shows the watch's value
|
||||
and the placeholder does NOT say "From group" — even with the group opted in.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
@@ -124,33 +312,32 @@ def test_watch_edit_llm_intent_shows_own_value_not_group_placeholder(
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Deals Group', llm_intent='Tag intent: notify on any deal')
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Deals Group',
|
||||
llm_intent='Tag intent: notify on any deal',
|
||||
ai=True)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
ds.data['watching'][watch_uuid]['llm_intent'] = 'My own watch intent'
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
text = _page_text(res)
|
||||
|
||||
# Watch's own value in the textarea body
|
||||
assert 'My own watch intent' in body
|
||||
assert 'My own watch intent' in text
|
||||
# No group placeholder — the watch has its own value
|
||||
assert 'From group' not in body
|
||||
assert 'From group' not in text
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Watch edit page — llm_change_summary group override
|
||||
# Watch edit page — llm_change_summary group override, gated on the checkbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_watch_edit_shows_llm_change_summary_placeholder_from_group(
|
||||
def test_watch_edit_shows_llm_change_summary_placeholder_when_group_overrides_enabled(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
When a watch has no own llm_change_summary but its first tag does,
|
||||
the edit page shows the group value as placeholder (editable, not readonly).
|
||||
"""
|
||||
"""Group override ON → the group summary prompt shows as placeholder (editable)."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
@@ -159,31 +346,58 @@ def test_watch_edit_shows_llm_change_summary_placeholder_from_group(
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
tag_uuid = _add_tag_with_llm(
|
||||
ds, 'Summary Group',
|
||||
llm_change_summary='List new items as bullet points. Translate to English.'
|
||||
llm_change_summary='List new items as bullet points. Translate to English.',
|
||||
ai=True,
|
||||
)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
text = _page_text(res)
|
||||
|
||||
assert 'Summary Group' in body
|
||||
assert 'List new items as bullet points' in body
|
||||
assert _from_group_text('Summary Group',
|
||||
'List new items as bullet points. Translate to English.') in text
|
||||
|
||||
# Field must be editable
|
||||
summary_pos = body.find('name="llm_change_summary"')
|
||||
summary_pos = text.find('name="llm_change_summary"')
|
||||
assert summary_pos != -1
|
||||
snippet = body[max(0, summary_pos - 50): summary_pos + 300]
|
||||
snippet = text[max(0, summary_pos - 50): summary_pos + 300]
|
||||
assert 'readonly' not in snippet, \
|
||||
f"llm_change_summary must be editable; snippet: {snippet!r}"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_watch_edit_hides_llm_change_summary_placeholder_when_group_overrides_disabled(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Group override OFF → no "From group ..." for the summary prompt either."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
tag_uuid = _add_tag_with_llm(
|
||||
ds, 'Summary Group',
|
||||
llm_change_summary='List new items as bullet points. Translate to English.',
|
||||
ai=False,
|
||||
)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert res.status_code == 200
|
||||
text = _page_text(res)
|
||||
|
||||
assert 'From group' not in text
|
||||
assert 'List new items as bullet points' not in text
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_watch_edit_llm_change_summary_shows_own_value_not_group_placeholder(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
When a watch has its own llm_change_summary, the textarea body shows the watch's
|
||||
When the watch has its own llm_change_summary, the textarea body shows the watch's
|
||||
value and no group placeholder appears.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
@@ -192,17 +406,18 @@ def test_watch_edit_llm_change_summary_shows_own_value_not_group_placeholder(
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Summary Group', llm_change_summary='Tag summary prompt')
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Summary Group', llm_change_summary='Tag summary prompt',
|
||||
ai=True)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
ds.data['watching'][watch_uuid]['llm_change_summary'] = 'My own summary prompt'
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
text = _page_text(res)
|
||||
|
||||
assert 'My own summary prompt' in body
|
||||
assert 'From group' not in body
|
||||
assert 'My own summary prompt' in text
|
||||
assert 'From group' not in text
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
@@ -230,8 +445,7 @@ def test_watch_edit_no_tag_fields_are_editable(
|
||||
# Neither textarea should be readonly
|
||||
for field in ('llm_intent', 'llm_change_summary'):
|
||||
pos = body.find(f'name="{field}"')
|
||||
if pos == -1:
|
||||
continue # field might not render if LLM section hidden for some reason
|
||||
assert pos != -1, f"{field} textarea missing from watch edit page"
|
||||
snippet = body[max(0, pos - 50): pos + 300]
|
||||
assert 'readonly' not in snippet, \
|
||||
f"{field} textarea must not be readonly with no tags; snippet: {snippet!r}"
|
||||
@@ -242,14 +456,14 @@ def test_watch_edit_no_tag_fields_are_editable(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluator cascade — group value used when watch has none
|
||||
# Evaluator cascade — gated on the same checkbox as the UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resolve_llm_field_uses_tag_value_when_watch_has_none(
|
||||
def test_resolve_llm_field_uses_tag_value_when_group_overrides_enabled(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
resolve_llm_field returns the tag's value (and tag name as source) when
|
||||
the watch has no own value.
|
||||
resolve_llm_field returns the tag's value (and tag name as source) when the watch
|
||||
has no own value and the group is opted in.
|
||||
"""
|
||||
from changedetectionio.llm.evaluator import resolve_llm_field
|
||||
|
||||
@@ -258,7 +472,8 @@ def test_resolve_llm_field_uses_tag_value_when_watch_has_none(
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Cascade Group', llm_intent='Group-level intent')
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Cascade Group', llm_intent='Group-level intent',
|
||||
ai=True)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
watch = ds.data['watching'][watch_uuid]
|
||||
@@ -270,10 +485,33 @@ def test_resolve_llm_field_uses_tag_value_when_watch_has_none(
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_resolve_llm_field_ignores_tag_value_when_group_overrides_disabled(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""The UI hint and the evaluator agree: no opt-in, no inheritance."""
|
||||
from changedetectionio.llm.evaluator import resolve_llm_field
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
api_token = _api_token(client)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Cascade Group', llm_intent='Group-level intent',
|
||||
ai=False)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
watch = ds.data['watching'][watch_uuid]
|
||||
value, source = resolve_llm_field(watch, ds, 'llm_intent')
|
||||
|
||||
assert value == ''
|
||||
assert source == ''
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_resolve_llm_field_uses_watch_value_over_tag(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
resolve_llm_field prefers the watch's own value over the tag's.
|
||||
resolve_llm_field prefers the watch's own value over the tag's, opted in or not.
|
||||
"""
|
||||
from changedetectionio.llm.evaluator import resolve_llm_field
|
||||
|
||||
@@ -282,7 +520,8 @@ def test_resolve_llm_field_uses_watch_value_over_tag(
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Override Group', llm_intent='Tag intent')
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Override Group', llm_intent='Tag intent',
|
||||
ai=True)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
ds.data['watching'][watch_uuid]['llm_intent'] = 'Watch-level intent'
|
||||
@@ -303,8 +542,8 @@ def test_resolve_llm_field_uses_watch_value_over_tag(
|
||||
def test_watch_edit_independent_field_overrides(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
llm_intent can come from a group (readonly) while llm_change_summary
|
||||
is editable (watch has its own), and vice versa.
|
||||
llm_intent can be inherited from an opted-in group while llm_change_summary
|
||||
is the watch's own value.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
@@ -316,6 +555,7 @@ def test_watch_edit_independent_field_overrides(
|
||||
ds, 'Mixed Group',
|
||||
llm_intent='Group intent here',
|
||||
llm_change_summary='Group summary here',
|
||||
ai=True,
|
||||
)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
@@ -324,21 +564,22 @@ def test_watch_edit_independent_field_overrides(
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
text = _page_text(res)
|
||||
|
||||
# llm_intent: group placeholder visible (watch has no own value)
|
||||
assert 'Group intent here' in body
|
||||
intent_pos = body.find('name="llm_intent"')
|
||||
assert _from_group_text('Mixed Group', 'Group intent here') in text
|
||||
intent_pos = text.find('name="llm_intent"')
|
||||
assert intent_pos != -1
|
||||
intent_snippet = body[max(0, intent_pos - 50): intent_pos + 300]
|
||||
intent_snippet = text[max(0, intent_pos - 50): intent_pos + 300]
|
||||
assert 'readonly' not in intent_snippet, \
|
||||
f"llm_intent must be editable even when group sets it; snippet: {intent_snippet!r}"
|
||||
|
||||
# llm_change_summary: watch own value shown in body, no group placeholder
|
||||
assert 'My own summary' in body
|
||||
summary_pos = body.find('name="llm_change_summary"')
|
||||
# llm_change_summary: watch own value shown in body, no group placeholder for it
|
||||
assert 'My own summary' in text
|
||||
assert _from_group_text('Mixed Group', 'Group summary here') not in text
|
||||
summary_pos = text.find('name="llm_change_summary"')
|
||||
assert summary_pos != -1
|
||||
summary_snippet = body[max(0, summary_pos - 50): summary_pos + 300]
|
||||
summary_snippet = text[max(0, summary_pos - 50): summary_pos + 300]
|
||||
assert 'readonly' not in summary_snippet, \
|
||||
f"llm_change_summary should be editable; snippet: {summary_snippet!r}"
|
||||
|
||||
@@ -409,7 +650,7 @@ def test_tag_edit_page_shows_prompt_mode_radio(
|
||||
|
||||
def test_tag_append_mode_persists_and_applies(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""A group set to append adds its text to the global prompt for its watches."""
|
||||
"""An opted-in group set to append adds its text to the global prompt for its watches."""
|
||||
from changedetectionio.llm.evaluator import get_effective_summary_prompt
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
@@ -420,7 +661,8 @@ def test_tag_append_mode_persists_and_applies(
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Append Group', llm_change_summary='Group extra line.')
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Append Group', llm_change_summary='Group extra line.',
|
||||
ai=True)
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary_mode'] = 'append'
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
@@ -428,3 +670,348 @@ def test_tag_append_mode_persists_and_applies(
|
||||
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL RULES\n\nGroup extra line.'
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_tag_append_mode_ignored_when_group_overrides_disabled(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Without the opt-in the group's appended text never reaches the effective prompt."""
|
||||
from changedetectionio.llm.evaluator import get_effective_summary_prompt
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
ds.data['settings']['application']['llm']['change_summary_default'] = 'GLOBAL RULES'
|
||||
|
||||
api_token = _api_token(client)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Append Group', llm_change_summary='Group extra line.',
|
||||
ai=False)
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary_mode'] = 'append'
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
watch = ds.data['watching'][watch_uuid]
|
||||
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL RULES'
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI on/off per watch, and per group when the group overrides — #4204
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_watch_edit_has_ai_enabled_checkbox(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Every watch gets its own AI on/off switch, on by default."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
|
||||
assert 'name="llm_backend_profile"' in body, \
|
||||
"watch edit page is missing the AI on/off checkbox (#4204)"
|
||||
assert _checkbox_is_checked(body, 'llm_backend_profile'), \
|
||||
"AI should default to on for a new watch"
|
||||
# No group involved, so no override note
|
||||
assert 'overrides this' not in _page_text(res)
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_group_edit_can_switch_ai_off_for_the_whole_group(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""The group's control has an explicit "Off for every watch" state — the #4204 ask."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
tag_uuid = ds.add_tag('AI Toggle Group')
|
||||
|
||||
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
|
||||
assert 'Off for every watch' in _page_text(res), \
|
||||
"group edit page cannot switch AI off for all of its watches (#4204)"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_group_edit_greys_out_the_prompts_unless_ai_is_on(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Same cue as the restock group override (#overrides_watch + toggleOpacity): the prompts
|
||||
only mean something in the "On" state, so they are greyed out otherwise. The state
|
||||
changes without a reload, so this pins the JS wiring rather than the opacity value.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
tag_uuid = ds.add_tag('Dimmed Group')
|
||||
|
||||
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
|
||||
assert "toggleOpacityByRadioValue('llm_backend_profile', 'true'" in body, \
|
||||
"group edit page lost the wiring that greys out the AI prompts"
|
||||
# ..and the elements it drives are all present
|
||||
for element_id in ('llm_backend_profile_true', 'change-intent-notify-me-when', 'change-summary'):
|
||||
assert f'id="{element_id}"' in body, f"#{element_id} missing — opacity toggle would be a no-op"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_watch_edit_shows_which_group_decided_the_ai_switch(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
A group that has taken the decision decides for its watches, so the watch edit page says
|
||||
which group is in charge and what it decided — and that explanation must stay readable
|
||||
(only the checkbox it describes is dimmed).
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
|
||||
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Tech news', ai=False)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
text = _page_text(res)
|
||||
assert 'decides this: AI is OFF for every watch in that group.' in text
|
||||
|
||||
# The group name links to that group's edit page, straight to its AI tab
|
||||
tag_edit_url = url_for('tags.form_tag_edit', uuid=tag_uuid)
|
||||
assert f'<a href="{tag_edit_url}#ai-llm">Tech news</a>' in text, \
|
||||
"the group name in the note must link to the group's edit page"
|
||||
|
||||
# The note itself is not inside the dimmed wrapper
|
||||
note_pos = text.find('decides this: AI is OFF')
|
||||
dimmed_pos = text.find('style="opacity: 0.6;"', text.find('id="llm-ai-enabled-row"'))
|
||||
assert dimmed_pos != -1, "the overridden checkbox should be dimmed"
|
||||
assert text.find('</div>', dimmed_pos) < note_pos, \
|
||||
"the 'Group X decides this' note must not be greyed out with the checkbox"
|
||||
|
||||
# Group switched to On → the note reflects that, still linked
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
|
||||
text = _page_text(client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid)))
|
||||
assert 'decides this: AI is ON for every watch in that group.' in text
|
||||
assert f'<a href="{tag_edit_url}#ai-llm">Tech news</a>' in text
|
||||
|
||||
# ..and the watch's own checkbox is disabled, showing what the group decided
|
||||
body = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid)).data.decode('utf-8', errors='replace')
|
||||
checkbox = _input_tags(body, 'llm_backend_profile')[0]
|
||||
assert 'disabled' in checkbox, "the group decides, so the watch's own checkbox must be disabled"
|
||||
assert 'checked' in checkbox, "disabled checkbox must show the state the group decided (ON)"
|
||||
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = False
|
||||
body = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid)).data.decode('utf-8', errors='replace')
|
||||
checkbox = _input_tags(body, 'llm_backend_profile')[0]
|
||||
assert 'disabled' in checkbox and 'checked' not in checkbox, \
|
||||
"disabled checkbox must show the state the group decided (OFF)"
|
||||
|
||||
# ..and with the group leaving it to each watch, the watch is on its own again
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = None
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert 'decides this' not in _page_text(res)
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_watch_own_ai_switch_survives_being_overridden_by_a_group(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
While a group decides, the watch's checkbox is disabled — and a disabled checkbox is not
|
||||
POSTed, which for a checkbox reads as "off". Saving the watch must therefore NOT quietly
|
||||
rewrite its own preference: it has to come back unchanged once the group stops deciding.
|
||||
"""
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
|
||||
# Watch says AI on (the default); the group overrules it with "off"
|
||||
tag_uuid = _add_tag_with_llm(ds, 'Deciding Group', ai=False)
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
assert ds.data['watching'][watch_uuid].get('llm_backend_profile') is True
|
||||
assert llm_enabled_for_watch(ds.data['watching'][watch_uuid], ds) == (False, 'Deciding Group')
|
||||
|
||||
# Save the page exactly as the browser would: the disabled checkbox sends nothing at all
|
||||
res = client.post(
|
||||
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
|
||||
data={'url': test_url, 'fetch_backend': 'html_requests',
|
||||
'time_between_check_use_default': 'y'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Updated watch' in res.data
|
||||
|
||||
watch = ds.data['watching'][watch_uuid]
|
||||
assert watch.get('llm_backend_profile') is True, \
|
||||
"saving while a group decides must not overwrite the watch's own AI preference"
|
||||
# Not even a hand-crafted POST can write it while it isn't user-editable
|
||||
res = client.post(
|
||||
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
|
||||
data={'url': test_url, 'fetch_backend': 'html_requests',
|
||||
'time_between_check_use_default': 'y', 'llm_backend_profile': ''},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Updated watch' in res.data
|
||||
watch = ds.data['watching'][watch_uuid]
|
||||
assert watch.get('llm_backend_profile') is True
|
||||
# The group still wins for now...
|
||||
assert llm_enabled_for_watch(watch, ds) == (False, 'Deciding Group')
|
||||
# ..and when the group stops deciding, the watch's untouched preference applies again
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = None
|
||||
assert llm_enabled_for_watch(watch, ds) == (True, 'watch')
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_watch_ai_switch_saves_via_edit_form(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Turning AI off on a watch persists, and turning it back on works."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
|
||||
# Unchecked checkbox is simply absent from the POST
|
||||
res = client.post(
|
||||
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
|
||||
data={'url': test_url, 'fetch_backend': 'html_requests',
|
||||
'time_between_check_use_default': 'y'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Updated watch' in res.data
|
||||
watch = ds.data['watching'][watch_uuid]
|
||||
assert watch.get('llm_backend_profile') is False
|
||||
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
|
||||
|
||||
res = client.post(
|
||||
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
|
||||
data={'url': test_url, 'fetch_backend': 'html_requests',
|
||||
'time_between_check_use_default': 'y', 'llm_backend_profile': 'y'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Updated watch' in res.data
|
||||
watch = ds.data['watching'][watch_uuid]
|
||||
assert watch.get('llm_backend_profile') is True
|
||||
assert llm_enabled_for_watch(watch, ds) == (True, 'watch')
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_group_ai_switch_saves_and_decides_for_its_watches(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Group form set to "Off for every watch" → every watch in the group is off, whatever the
|
||||
watch itself says. This is the #4204 "turn AI off for a whole group" flow.
|
||||
"""
|
||||
from changedetectionio.llm.evaluator import llm_enabled_for_watch
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
|
||||
|
||||
res = client.post(url_for('tags.form_tag_add'), data={'name': 'Budget Group'}, follow_redirects=True)
|
||||
assert b'Tag added' in res.data
|
||||
tag_uuid = [u for u, t in ds.data['settings']['application']['tags'].items()
|
||||
if t.get('title') == 'Budget Group'][0]
|
||||
|
||||
res = client.post(
|
||||
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
|
||||
data={'title': 'Budget Group', 'llm_backend_profile': 'false'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Updated' in res.data
|
||||
tag = ds.data['settings']['application']['tags'][tag_uuid]
|
||||
assert tag.get('llm_backend_profile') is False
|
||||
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
watch = ds.data['watching'][watch_uuid]
|
||||
assert watch.get('llm_backend_profile') is True # the watch itself still says "on"
|
||||
assert llm_enabled_for_watch(watch, ds) == (False, 'Budget Group'), \
|
||||
"a group set to Off must win over the watch's own AI switch"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_group_ai_state_survives_a_save_with_no_llm_configured(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
With no provider configured the AI control isn't rendered, and an unrendered control is
|
||||
indistinguishable from "off" in a POST — so the page must carry the saved state in a
|
||||
hidden input, otherwise merely saving the group would switch AI off.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
# deliberately NOT calling _configure_llm
|
||||
|
||||
res = client.post(url_for('tags.form_tag_add'), data={'name': 'Unconfigured Group'}, follow_redirects=True)
|
||||
assert b'Tag added' in res.data
|
||||
tag_uuid = [u for u, t in ds.data['settings']['application']['tags'].items()
|
||||
if t.get('title') == 'Unconfigured Group'][0]
|
||||
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
|
||||
|
||||
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
assert 'name="llm_intent"' not in body, "AI fields should not render without a provider"
|
||||
hidden = _input_tag(body, 'llm_backend_profile')
|
||||
assert 'type="hidden"' in hidden and 'value="true"' in hidden, \
|
||||
"the group AI state must be preserved in a hidden input when the AI section is not rendered"
|
||||
|
||||
# Submit exactly what that page would send
|
||||
res = client.post(
|
||||
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
|
||||
data={'title': 'Unconfigured Group', 'llm_backend_profile': 'true'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Updated' in res.data
|
||||
|
||||
tag = ds.data['settings']['application']['tags'][tag_uuid]
|
||||
assert tag.get('llm_backend_profile') is True, \
|
||||
"saving with the AI section hidden must not switch AI off"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end through the real forms — the path the user actually clicks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_group_override_round_trip_through_both_forms(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Set the group to On with an intent via the group form, tag a watch with it, and the watch
|
||||
edit page shows the inherited value as its placeholder. This is the flow that was broken:
|
||||
the group had no way to switch group-wide AI settings on at all.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
watch_uuid = _create_watch(client, test_url, api_token)
|
||||
|
||||
res = client.post(url_for('tags.form_tag_add'), data={'name': 'E2E Group'}, follow_redirects=True)
|
||||
assert b'Tag added' in res.data
|
||||
tag_uuid = [u for u, t in ds.data['settings']['application']['tags'].items()
|
||||
if t.get('title') == 'E2E Group'][0]
|
||||
|
||||
res = client.post(
|
||||
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
|
||||
data={'title': 'E2E Group',
|
||||
'llm_intent': 'Only tell me about stock changes',
|
||||
'llm_backend_profile': 'true'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Updated' in res.data
|
||||
|
||||
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
|
||||
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
|
||||
assert _from_group_text('E2E Group', 'Only tell me about stock changes') in _page_text(res)
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_rss_tag_feed_ignores_security_token(client, live_server, datastore_path
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Logout
|
||||
client.get(url_for("logout"), follow_redirects=True)
|
||||
client.post(url_for("logout"), follow_redirects=True)
|
||||
|
||||
# Request the tag RSS feed WITH the token
|
||||
res = client.get(
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Regression tests for two scheduler/timezone bugs.
|
||||
|
||||
BUG 1 - edit page returned HTTP 500
|
||||
ui/edit.py resolved the default timezone with
|
||||
.get('scheduler_timezone_default', os.getenv('TZ', 'UTC'))
|
||||
but App.py initialises that key to None, so the key EXISTS and dict.get()
|
||||
never uses its fallback. None reached is_within_schedule(), which does
|
||||
`tz_name.strip()` -> AttributeError -> the handler did `return False` -> Flask
|
||||
raised TypeError ("view function did not return a valid response ... it was a
|
||||
bool") -> HTTP 500. The watch was actually saved, so the user saw a 500 on a
|
||||
successful save.
|
||||
|
||||
BUG 2 - ticker thread died silently
|
||||
Same root cause, but flask_app.py's `return False` was inside the ticker
|
||||
thread's main loop. One watch with an unresolvable timezone ended the thread
|
||||
and NO watch was ever checked again until restart, with the process still up.
|
||||
|
||||
Run: pytest changedetectionio/tests/test_scheduler_timezone_regressions.py
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from .util import live_server_setup, wait_for_all_checks, set_original_response
|
||||
|
||||
|
||||
DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||
|
||||
|
||||
def _schedule_form(url, enabled_days=('monday',)):
|
||||
data = {
|
||||
"url": url,
|
||||
"fetch_backend": "html_requests",
|
||||
"time_between_check_use_default": "",
|
||||
"time_between_check-seconds": 1,
|
||||
"time_schedule_limit-enabled": 'y',
|
||||
}
|
||||
for day in DAYS:
|
||||
data[f"time_schedule_limit-{day}-start_time"] = "00:00"
|
||||
data[f"time_schedule_limit-{day}-duration-hours"] = 24
|
||||
data[f"time_schedule_limit-{day}-duration-minutes"] = 0
|
||||
if day in enabled_days:
|
||||
data[f"time_schedule_limit-{day}-enabled"] = 'y'
|
||||
return data
|
||||
|
||||
|
||||
def _full_schedule(enabled=True, timezone=None):
|
||||
sched = {'enabled': True}
|
||||
for day in DAYS:
|
||||
sched[day] = {'enabled': enabled, 'start_time': '00:00',
|
||||
'duration': {'hours': '24', 'minutes': '00'}}
|
||||
if timezone is not None:
|
||||
sched['timezone'] = timezone
|
||||
return sched
|
||||
|
||||
|
||||
def test_edit_page_saves_when_no_default_timezone_configured(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""BUG 1: enabling the scheduler with scheduler_timezone_default unset 500'd."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
# This is the default state from model/App.py - key present, value None.
|
||||
datastore.data['settings']['application']['scheduler_timezone_default'] = None
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
||||
data=_schedule_form(test_url),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
assert res.status_code == 200, f"expected 200, got {res.status_code}"
|
||||
assert b"Internal Server Error" not in res.data
|
||||
assert b"Updated watch." in res.data
|
||||
|
||||
# and the schedule really was saved
|
||||
assert datastore.data['watching'][uuid]['time_schedule_limit']['enabled'] is True
|
||||
|
||||
|
||||
def test_edit_page_saves_with_unresolvable_timezone(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""A garbage timezone must degrade to 'skip the recheck', not 500."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
datastore.data['settings']['application']['scheduler_timezone_default'] = 'Not/ARealZone'
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid),
|
||||
data=_schedule_form(test_url),
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 200, f"expected 200, got {res.status_code}"
|
||||
assert b"Updated watch." in res.data
|
||||
|
||||
|
||||
def test_ticker_survives_a_watch_with_a_broken_timezone(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
BUG 2: one bad watch used to kill the ticker thread, stopping every other
|
||||
watch. Prove a healthy watch still gets checked alongside a broken one.
|
||||
"""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
datastore.data['settings']['application']['scheduler_timezone_default'] = None
|
||||
|
||||
broken_uuid = datastore.add_watch(url=test_url)
|
||||
healthy_uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Broken watch: scheduler on, timezone that arrow cannot resolve
|
||||
datastore.data['watching'][broken_uuid]['time_between_check_use_default'] = False
|
||||
datastore.data['watching'][broken_uuid]['time_schedule_limit'] = _full_schedule(
|
||||
timezone='Not/ARealZone'
|
||||
)
|
||||
|
||||
# Healthy watch: no schedule limit, short interval so the ticker must pick it up
|
||||
datastore.data['watching'][healthy_uuid]['time_between_check_use_default'] = False
|
||||
datastore.data['watching'][healthy_uuid]['time_between_check'] = {
|
||||
'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': 2
|
||||
}
|
||||
before = datastore.data['watching'][healthy_uuid]['last_checked']
|
||||
|
||||
# Give the ticker several passes over the broken watch
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
if datastore.data['watching'][healthy_uuid]['last_checked'] != before:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
assert datastore.data['watching'][healthy_uuid]['last_checked'] != before, (
|
||||
"the healthy watch was never rechecked - the ticker thread most likely "
|
||||
"died on the broken watch's timezone"
|
||||
)
|
||||
|
||||
|
||||
def test_api_rejects_invalid_timezone_on_update(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
The edit form runs validateTimeZoneName, but the API did not - the OpenAPI
|
||||
schema for time_schedule_limit declares no `timezone` property and does not
|
||||
set additionalProperties:false, so any string was accepted and stored.
|
||||
A bogus zone makes arrow.now(tz) raise inside the scheduler.
|
||||
"""
|
||||
import json
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
api_key = datastore.data['settings']['application'].get('api_access_token')
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.put(
|
||||
url_for("watch", uuid=uuid),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps({'time_schedule_limit': _full_schedule(timezone='Not/ARealZone')}),
|
||||
)
|
||||
assert res.status_code == 400, f"expected 400, got {res.status_code}: {res.data}"
|
||||
assert b'not a valid timezone' in res.data.lower()
|
||||
|
||||
stored = datastore.data['watching'][uuid]['time_schedule_limit']
|
||||
assert stored.get('timezone') != 'Not/ARealZone', "invalid timezone was persisted anyway"
|
||||
|
||||
|
||||
def test_api_accepts_valid_timezone_on_update(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""The validation must not block legitimate zones."""
|
||||
import json
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
api_key = datastore.data['settings']['application'].get('api_access_token')
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
for tz in ('Europe/Berlin', 'UTC', 'Pacific/Kiritimati'):
|
||||
res = client.put(
|
||||
url_for("watch", uuid=uuid),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps({'time_schedule_limit': _full_schedule(timezone=tz)}),
|
||||
)
|
||||
assert res.status_code == 200, f"{tz} rejected: {res.data}"
|
||||
assert datastore.data['watching'][uuid]['time_schedule_limit']['timezone'] == tz
|
||||
|
||||
# omitting the timezone entirely stays valid
|
||||
res = client.put(
|
||||
url_for("watch", uuid=uuid),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps({'time_schedule_limit': _full_schedule()}),
|
||||
)
|
||||
assert res.status_code == 200, res.data
|
||||
|
||||
|
||||
def test_api_rejects_invalid_timezone_on_create(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Same guard on POST /watch, otherwise it is just a different door."""
|
||||
import json
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
api_key = datastore.data['settings']['application'].get('api_access_token')
|
||||
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps({'url': test_url,
|
||||
'time_schedule_limit': _full_schedule(timezone='Bogus/Zone')}),
|
||||
)
|
||||
assert res.status_code == 400, f"expected 400, got {res.status_code}: {res.data}"
|
||||
assert b'not a valid timezone' in res.data.lower()
|
||||
|
||||
|
||||
def test_ignore_text_via_selection_marks_watch_edited(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
The selection UI appended to ignore_text in place. That bypasses
|
||||
watch_base.__setitem__, so was_edited stayed False and the
|
||||
'content unchanged since last check' skip stayed active - the new
|
||||
ignore_text would not take effect until the page changed on its own.
|
||||
"""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
watch = datastore.data['watching'][uuid]
|
||||
watch.reset_watch_edited_flag()
|
||||
assert watch.was_edited is False, "precondition: flag should start clear"
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid),
|
||||
data={'mode': 'exact', 'selection': 'Which is across multiple lines'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
watch = datastore.data['watching'][uuid]
|
||||
assert 'Which is across multiple lines' in watch['ignore_text'], "the text was not stored"
|
||||
assert watch.was_edited is True, (
|
||||
"watch was not flagged as edited - the new ignore_text will not be applied "
|
||||
"until the page content changes on its own"
|
||||
)
|
||||
|
||||
|
||||
def test_ignore_text_digit_regex_marks_watch_edited(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Same for the digit-regex branch."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
datastore.data['watching'][uuid].reset_watch_edited_flag()
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid),
|
||||
data={'mode': 'digit-regex', 'selection': 'Updated 1234 times'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
watch = datastore.data['watching'][uuid]
|
||||
assert any(t.startswith('/') and t.endswith('/') for t in watch['ignore_text']), \
|
||||
f"expected a regex entry, got {watch['ignore_text']}"
|
||||
assert watch.was_edited is True
|
||||
|
||||
|
||||
def test_ignore_text_appends_to_existing(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Rebinding must preserve entries already there, not replace them."""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
wait_for_all_checks(client)
|
||||
datastore.data['watching'][uuid]['ignore_text'] = ['already here']
|
||||
|
||||
client.post(
|
||||
url_for("ui.ui_edit.highlight_submit_ignore_url", uuid=uuid),
|
||||
data={'mode': 'exact', 'selection': 'line one\nline two'},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
ignore_text = datastore.data['watching'][uuid]['ignore_text']
|
||||
assert 'already here' in ignore_text, "existing entries were lost"
|
||||
assert 'line one' in ignore_text
|
||||
assert 'line two' in ignore_text
|
||||
@@ -440,7 +440,7 @@ def test_login_redirect_with_password(client, live_server, measure_memory_usage,
|
||||
assert b"evil.com" not in res.data
|
||||
|
||||
# Logout for cleanup
|
||||
client.get(url_for("logout"))
|
||||
client.post(url_for("logout"))
|
||||
|
||||
# Test 5: Incorrect password with redirect should stay on login page
|
||||
res = client.post(
|
||||
@@ -483,7 +483,7 @@ def test_login_redirect_from_protected_page(client, live_server, measure_memory_
|
||||
client.application.config['DATASTORE'].data['settings']['application']['password'] = salted_pass
|
||||
|
||||
# Logout to ensure we're not authenticated
|
||||
client.get(url_for("logout"))
|
||||
client.post(url_for("logout"))
|
||||
|
||||
# Try to access a protected page (edit page for first watch)
|
||||
res = client.get(
|
||||
@@ -524,7 +524,7 @@ def test_login_redirect_from_protected_page(client, live_server, measure_memory_
|
||||
assert b'Edit' in res.data or b'Watching' in res.data
|
||||
|
||||
# Cleanup
|
||||
client.get(url_for("logout"))
|
||||
client.post(url_for("logout"))
|
||||
del client.application.config['DATASTORE'].data['settings']['application']['password']
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ def test_logout_with_redirect(client, live_server, measure_memory_usage, datasto
|
||||
assert res.status_code == 200
|
||||
|
||||
# Now logout with a redirect parameter (simulating logout from /settings)
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("logout", redirect="/settings"),
|
||||
follow_redirects=False
|
||||
)
|
||||
@@ -961,7 +961,7 @@ def test_ghsa_8757_69j2_hx56_backup_restore_history_path_traversal(client, live_
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Download a legitimate backup to use as a template
|
||||
client.get(url_for("backups.request_backup"), follow_redirects=True)
|
||||
client.post(url_for("backups.request_backup"), follow_redirects=True)
|
||||
time.sleep(4)
|
||||
res = client.get(url_for("backups.download_backup", filename="latest"), follow_redirects=True)
|
||||
assert res.content_type == "application/zip"
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_share_watch(client, live_server, measure_memory_usage, datastore_path):
|
||||
assert bytes(include_filters.encode('utf-8')) in res.data
|
||||
|
||||
# click share the link
|
||||
res = client.get(
|
||||
res = client.post(
|
||||
url_for("ui.form_share_put_watch", uuid=uuid),
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Guards for the translation overlay layer (changedetectionio/translations_overlay).
|
||||
|
||||
The overlay is a second gettext tree merged on top of ``changedetectionio/translations``, letting a
|
||||
deployment reword individual strings without editing the ``_()`` call site. See that directory's
|
||||
README.md. Three separate things can break it, so there is a test for each.
|
||||
|
||||
1. Overlay entries key on the exact upstream msgid. When a string is reworded upstream the override
|
||||
stops matching and silently reverts to upstream wording - no error, no log entry.
|
||||
``test_overlay_catalogs_are_valid`` makes that a build failure.
|
||||
|
||||
2. The layering relies on Flask-Babel merging catalogs with ``dict.update`` semantics (later
|
||||
directory wins per-message). Were a Flask-Babel upgrade to change that to an ``add_fallback``
|
||||
chain, overrides would stop applying while everything still looked fine.
|
||||
``test_overlay_overrides_a_string_in_a_rendered_page`` pins it against a real rendered page.
|
||||
|
||||
3. The directory has to actually reach ``BABEL_TRANSLATION_DIRECTORIES``.
|
||||
``test_overlay_dir_*`` cover the wiring in flask_app.py, including the env var.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from babel.messages.catalog import Catalog
|
||||
from babel.messages.mofile import write_mo
|
||||
from flask import url_for
|
||||
|
||||
PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
REPO_ROOT = os.path.dirname(PKG_DIR)
|
||||
OVERLAY_DIR = os.path.join(PKG_DIR, 'translations_overlay')
|
||||
BASE_DIR = os.path.join(PKG_DIR, 'translations')
|
||||
MANAGE = os.path.join(OVERLAY_DIR, 'manage.py')
|
||||
|
||||
# A msgid that renders as a settings-page tab label. The test asserts it is present *before*
|
||||
# overriding it, so a rename upstream fails loudly rather than making the test silently vacuous.
|
||||
OVERRIDDEN_MSGID = 'Global Filters'
|
||||
SENTINEL = 'zzOverlaySentinelFiltersZZ'
|
||||
|
||||
|
||||
def _write_mo(root, locale, entries):
|
||||
"""Write a compiled catalog at <root>/<locale>/LC_MESSAGES/messages.mo."""
|
||||
catalog = Catalog(locale=locale, domain='messages')
|
||||
for msgid, msgstr in entries.items():
|
||||
catalog.add(msgid, msgstr)
|
||||
mo_dir = os.path.join(root, locale, 'LC_MESSAGES')
|
||||
os.makedirs(mo_dir, exist_ok=True)
|
||||
with open(os.path.join(mo_dir, 'messages.mo'), 'wb') as fp:
|
||||
write_mo(fp, catalog)
|
||||
|
||||
|
||||
def _import_app_with(env_overlay_dir):
|
||||
"""Import flask_app in a clean subprocess and report its BABEL_TRANSLATION_DIRECTORIES.
|
||||
|
||||
Has to be a subprocess: the config is built at module import time, so it cannot be re-evaluated
|
||||
under a different environment once flask_app is already in sys.modules.
|
||||
"""
|
||||
env = dict(os.environ, TRANSLATION_OVERLAY_DIR=env_overlay_dir)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
'-c',
|
||||
'from changedetectionio import flask_app;'
|
||||
'print("DIRS=" + flask_app.app.config["BABEL_TRANSLATION_DIRECTORIES"])',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
assert result.returncode == 0, f"importing flask_app failed:\n{result.stdout}\n{result.stderr}"
|
||||
line = [l for l in result.stdout.splitlines() if l.startswith('DIRS=')]
|
||||
assert line, f"no config line in output:\n{result.stdout}\n{result.stderr}"
|
||||
return line[0][len('DIRS=') :].split(';')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The overlay catalogs shipped in this repo are internally consistent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.path.isdir(OVERLAY_DIR), reason='no translation overlay in this deployment'
|
||||
)
|
||||
def test_overlay_catalogs_are_valid():
|
||||
"""Every override must still match an upstream msgid, be non-empty, and be compiled.
|
||||
|
||||
A failure here usually means upstream edited a string the overlay overrides. Re-copy the new
|
||||
msgid verbatim from translations/messages.pot into the overlay catalog, then recompile with
|
||||
`python changedetectionio/translations_overlay/manage.py compile`.
|
||||
"""
|
||||
result = subprocess.run([sys.executable, MANAGE, 'check'], capture_output=True, text=True)
|
||||
assert result.returncode == 0, (
|
||||
f"translation overlay is invalid:\n{result.stdout}{result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.path.isdir(OVERLAY_DIR), reason='no translation overlay in this deployment'
|
||||
)
|
||||
def test_overlay_locales_have_a_base_catalog():
|
||||
"""An overlay for a locale the app does not ship never loads, so it is silently dead."""
|
||||
for locale in sorted(os.listdir(OVERLAY_DIR)):
|
||||
if not os.path.isfile(os.path.join(OVERLAY_DIR, locale, 'LC_MESSAGES', 'messages.po')):
|
||||
continue
|
||||
assert os.path.isdir(os.path.join(BASE_DIR, locale)), (
|
||||
f"overlay locale {locale!r} has no base catalog in translations/"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The merge actually happens, end to end, on a real page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_overlay_overrides_a_string_in_a_rendered_page(client, live_server, tmp_path):
|
||||
"""A real overlay catalog changes real rendered output, and only the string it names."""
|
||||
app = client.application
|
||||
|
||||
# The rest of this test injects its own directory, which would still pass if flask_app.py had
|
||||
# stopped configuring the real one. Tie the two together so that regression fails here too.
|
||||
if os.path.isdir(OVERLAY_DIR):
|
||||
configured = app.config['BABEL_TRANSLATION_DIRECTORIES'].split(';')
|
||||
assert OVERLAY_DIR in configured, (
|
||||
f"{OVERLAY_DIR} exists but is not in BABEL_TRANSLATION_DIRECTORIES ({configured})"
|
||||
)
|
||||
|
||||
baseline = client.get(url_for('settings.settings_page'))
|
||||
assert baseline.status_code == 200
|
||||
assert OVERRIDDEN_MSGID.encode() in baseline.data, (
|
||||
f"{OVERRIDDEN_MSGID!r} no longer renders on the settings page - this test needs a new msgid"
|
||||
)
|
||||
assert SENTINEL.encode() not in baseline.data
|
||||
|
||||
overlay = tmp_path / 'overlay'
|
||||
# en_GB is BABEL_DEFAULT_LOCALE, and the test client sends no Accept-Language header
|
||||
_write_mo(str(overlay), 'en_GB', {OVERRIDDEN_MSGID: SENTINEL})
|
||||
|
||||
# The default Domain delegates to the app-level directory list, and caches per (locale, domain),
|
||||
# so both have to be touched for a new catalog to be picked up mid-process.
|
||||
dirs = app.extensions['babel'].translation_directories
|
||||
domain_cache = app.extensions['babel'].instance.domain_instance.cache
|
||||
dirs.append(str(overlay))
|
||||
domain_cache.clear()
|
||||
try:
|
||||
overridden = client.get(url_for('settings.settings_page'))
|
||||
assert overridden.status_code == 200
|
||||
assert SENTINEL.encode() in overridden.data, (
|
||||
'overlay catalog did not override the base catalog'
|
||||
)
|
||||
assert OVERRIDDEN_MSGID.encode() not in overridden.data, 'base wording is still rendering'
|
||||
# Neighbouring tab label, deliberately not in the overlay - merging must not drop it
|
||||
assert b'UI Options' in overridden.data, (
|
||||
'overlay replaced the catalog instead of merging into it'
|
||||
)
|
||||
finally:
|
||||
dirs.remove(str(overlay))
|
||||
domain_cache.clear()
|
||||
|
||||
restored = client.get(url_for('settings.settings_page'))
|
||||
assert SENTINEL.encode() not in restored.data
|
||||
assert OVERRIDDEN_MSGID.encode() in restored.data, 'base wording did not come back'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. flask_app.py wires the directory up, and stays a no-op when there isn't one
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_overlay_dir_from_env_var_is_used(tmp_path):
|
||||
overlay = tmp_path / 'my-overlay'
|
||||
overlay.mkdir()
|
||||
dirs = _import_app_with(str(overlay))
|
||||
assert dirs[-1] == str(overlay), f"TRANSLATION_OVERLAY_DIR not appended, got {dirs}"
|
||||
assert dirs[0] == BASE_DIR, 'base catalog must stay first so the overlay wins on conflicts'
|
||||
|
||||
|
||||
def test_missing_overlay_dir_is_a_noop(tmp_path):
|
||||
"""No overlay directory means the config is exactly what it was before the feature existed."""
|
||||
dirs = _import_app_with(str(tmp_path / 'does-not-exist'))
|
||||
assert dirs == [BASE_DIR], f"expected only the base catalog, got {dirs}"
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PAGE_WATCH_LIMIT - the optional cap on how many watches one instance will hold.
|
||||
|
||||
The limit is enforced in datastore.add_watch(), which every add path funnels through, but each
|
||||
surface has to report it in its own terms: a flash for the UI, a 429 for the API, one flash for
|
||||
a whole file in the importers, and nothing at all (just a None) where there's no request context.
|
||||
"""
|
||||
|
||||
import json
|
||||
from flask import url_for
|
||||
from .util import delete_all_watches
|
||||
|
||||
|
||||
def test_watch_limit_absent_or_junk_means_unlimited(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""No env var, an empty one, or an unparseable one all leave the limit switched off."""
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
monkeypatch.delenv('PAGE_WATCH_LIMIT', raising=False)
|
||||
assert datastore.watch_limit is None
|
||||
assert datastore.watch_limit_reached() is False
|
||||
|
||||
# Junk must not block every add, and must not raise
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', 'not-a-number')
|
||||
assert datastore.watch_limit is None
|
||||
assert datastore.watch_limit_reached() is False
|
||||
|
||||
# Set-but-empty is the same as unset
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', '')
|
||||
assert datastore.watch_limit is None
|
||||
assert datastore.watch_limit_reached() is False
|
||||
|
||||
|
||||
def test_api_create_watch_refused_at_limit(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
|
||||
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": test_url}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": f"{test_url}?second=1"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 429
|
||||
assert b'Watch limit reached (1/1 watches)' in res.data
|
||||
assert len(datastore.data['watching']) == 1
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_api_import_refuses_the_whole_batch(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""A 429 from import always means nothing was created, so the same request can be retried."""
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
headers = {'x-api-key': api_key, 'content-type': 'text/plain'}
|
||||
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', '3')
|
||||
|
||||
res = client.post(url_for("import"), data=f"{test_url}?a=1\n{test_url}?a=2", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert len(res.json) == 2
|
||||
|
||||
# Two more would make four - refused whole rather than importing the one that fits
|
||||
res = client.post(url_for("import"), data=f"{test_url}?a=3\n{test_url}?a=4", headers=headers)
|
||||
assert res.status_code == 429
|
||||
assert b'would exceed it' in res.data
|
||||
assert len(datastore.data['watching']) == 2
|
||||
|
||||
# The one that does fit still goes in
|
||||
res = client.post(url_for("import"), data=f"{test_url}?a=3", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert len(datastore.data['watching']) == 3
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_quick_watch_add_refused_at_limit(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
|
||||
|
||||
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
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_views.form_quick_watch_add"),
|
||||
data={"url": f"{test_url}?second=1", 'tags': ''},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b'Watch limit reached (1/1 watches)' in res.data
|
||||
assert b'Watch added' not in res.data
|
||||
assert len(datastore.data['watching']) == 1
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_ui_import_reports_limit_once_and_hands_back_the_rest(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""The importer stops at the limit instead of letting add_watch() flash per remaining row."""
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', '2')
|
||||
|
||||
urls = "\n".join(f"{test_url}?i={i}" for i in range(5))
|
||||
res = client.post(url_for("imports.import_page"), data={"urls": urls}, follow_redirects=True)
|
||||
|
||||
assert res.data.count(b'Watch limit reached') == 1, "The limit should be reported once for the file, not once per row"
|
||||
assert len(datastore.data['watching']) == 2
|
||||
# 3 unprocessed URLs come back in the textarea to retry once there's room
|
||||
assert b'3 Skipped' in res.data
|
||||
assert b'i=4' in res.data
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_clone_refused_at_limit(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""Clone used to raise KeyError(None) here, then redirect to an edit page for uuid=None."""
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
uuid = datastore.add_watch(url=test_url)
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
|
||||
|
||||
res = client.post(url_for("ui.form_clone", uuid=uuid), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
assert b'Watch limit reached (1/1 watches)' in res.data
|
||||
assert b'Cloned' not in res.data
|
||||
assert len(datastore.data['watching']) == 1
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_over_limit_instance_still_loads_and_edits(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""A limit set below what an install already holds must only block *new* watches.
|
||||
|
||||
Everything already there keeps loading from disk and stays editable - the limit is not
|
||||
retroactive and never hides or drops a watch.
|
||||
"""
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
monkeypatch.delenv('PAGE_WATCH_LIMIT', raising=False)
|
||||
uuids = [datastore.add_watch(url=f"{test_url}?i={i}") for i in range(3)]
|
||||
assert all(uuids)
|
||||
|
||||
# Now cap it well below what's already stored
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
|
||||
assert datastore.watch_limit_reached() is True
|
||||
|
||||
# Re-reading from disk is not gated by the limit
|
||||
datastore._load_watches()
|
||||
assert len(datastore.data['watching']) == 3
|
||||
|
||||
# Still all listed
|
||||
assert client.get(url_for("watchlist.index")).status_code == 200
|
||||
res = client.get(url_for("createwatch"), headers={'x-api-key': api_key})
|
||||
assert len(res.json) == 3
|
||||
|
||||
# And still editable
|
||||
res = client.put(
|
||||
url_for("watch", uuid=uuids[0]),
|
||||
data=json.dumps({"title": "Still editable"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert datastore.data['watching'][uuids[0]].get('title') == "Still editable"
|
||||
|
||||
# Only adding is refused
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": f"{test_url}?new=1"}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 429
|
||||
assert len(datastore.data['watching']) == 3
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_limit_shown_in_settings_info_tab(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""The Info tab names the limit only when one is configured."""
|
||||
monkeypatch.delenv('PAGE_WATCH_LIMIT', raising=False)
|
||||
res = client.get(url_for("settings.settings_page"))
|
||||
assert b'Maximum number of page watches' not in res.data
|
||||
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', '42')
|
||||
res = client.get(url_for("settings.settings_page"))
|
||||
assert b'Maximum number of page watches' in res.data
|
||||
assert b'42' in res.data
|
||||
|
||||
|
||||
def test_limit_reported_without_a_request_context(client, live_server, measure_memory_usage, datastore_path, monkeypatch, mocker):
|
||||
"""The CLI (-u) and the API's background import thread call add_watch() with no request
|
||||
context, where flash() raises RuntimeError instead of reporting anything."""
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
monkeypatch.setenv('PAGE_WATCH_LIMIT', '1')
|
||||
assert datastore.add_watch(url=test_url)
|
||||
|
||||
# pytest-flask pushes a request context around every test, so take it away
|
||||
mocker.patch('changedetectionio.store.has_request_context', return_value=False)
|
||||
|
||||
assert datastore.add_watch(url=f"{test_url}?second=1") is None
|
||||
assert len(datastore.data['watching']) == 1
|
||||
|
||||
delete_all_watches(client)
|
||||
@@ -595,11 +595,11 @@ def test_rss_xpath(client, live_server, measure_memory_usage, datastore_path):
|
||||
|
||||
|
||||
# GHSA-6fmw-82m7-jq6p — XPath arbitrary file read via unparsed-text() and friends
|
||||
# Unit-level: verify xpath_filter() and SafeXPath3Parser block all dangerous functions.
|
||||
# Unit-level: verify xpath_filter() and the safe XPath3 parser block all dangerous functions.
|
||||
def test_xpath_blocked_functions_unit():
|
||||
"""Dangerous XPath 3.0 functions must be rejected at the parser level (no live server needed)."""
|
||||
import elementpath
|
||||
from changedetectionio.html_tools import xpath_filter, SafeXPath3Parser
|
||||
from changedetectionio.html_tools import xpath_filter, get_safe_xpath3_parser
|
||||
from lxml import html
|
||||
|
||||
html_content = '<html><body><p>safe content</p></body></html>'
|
||||
@@ -627,11 +627,11 @@ def test_xpath_blocked_functions_unit():
|
||||
except elementpath.ElementPathError:
|
||||
pass # expected
|
||||
|
||||
# SafeXPath3Parser must reject the expression at parse time
|
||||
# the safe parser must reject the expression at parse time
|
||||
tree = html.fromstring(html_content)
|
||||
try:
|
||||
elementpath.select(tree, expr, parser=SafeXPath3Parser)
|
||||
assert False, f"SafeXPath3Parser should have raised for: {expr!r}"
|
||||
elementpath.select(tree, expr, parser=get_safe_xpath3_parser())
|
||||
assert False, f"safe XPath3 parser should have raised for: {expr!r}"
|
||||
except elementpath.ElementPathError:
|
||||
pass # expected
|
||||
|
||||
|
||||
@@ -79,5 +79,69 @@ class TestTriggerConditions(unittest.TestCase):
|
||||
self.assertTrue(result.get('result'))
|
||||
|
||||
|
||||
|
||||
def test_conditions_filter_complete_rules_with_zero_values(self):
|
||||
from changedetectionio.conditions import filter_complete_rules
|
||||
|
||||
rules = [
|
||||
{"operator": "==", "field": "word_count", "value": 0},
|
||||
{"operator": "<=", "field": "levenshtein_distance", "value": 0.0},
|
||||
{"operator": "==", "field": "price", "value": "0"},
|
||||
{"operator": "==", "field": "empty_val", "value": ""},
|
||||
{"operator": "==", "field": "none_val", "value": None},
|
||||
{"operator": "==", "field": "str_none", "value": "None"},
|
||||
{"operator": "", "field": "missing_op", "value": 0},
|
||||
{"operator": "None", "field": "str_none_op", "value": 0},
|
||||
{"operator": "==", "field": "", "value": 0},
|
||||
]
|
||||
complete = filter_complete_rules(rules)
|
||||
self.assertEqual(len(complete), 3)
|
||||
self.assertEqual(complete[0]["value"], 0)
|
||||
self.assertEqual(complete[1]["value"], 0.0)
|
||||
self.assertEqual(complete[2]["value"], "0")
|
||||
|
||||
def test_conditions_convert_to_jsonlogic_with_zero_values(self):
|
||||
from json_logic import jsonLogic
|
||||
from changedetectionio.conditions import convert_to_jsonlogic
|
||||
|
||||
rule_int_zero = [{"operator": "==", "field": "word_count", "value": 0}]
|
||||
jl_int = convert_to_jsonlogic("and", rule_int_zero)
|
||||
self.assertEqual(jl_int, {"==": [{"var": "word_count"}, 0]})
|
||||
self.assertTrue(jsonLogic(jl_int, {"word_count": 0}))
|
||||
self.assertFalse(jsonLogic(jl_int, {"word_count": 5}))
|
||||
|
||||
rule_float_zero = [{"operator": "<=", "field": "levenshtein_distance", "value": 0.0}]
|
||||
jl_float = convert_to_jsonlogic("and", rule_float_zero)
|
||||
self.assertEqual(jl_float, {"<=": [{"var": "levenshtein_distance"}, 0.0]})
|
||||
self.assertTrue(jsonLogic(jl_float, {"levenshtein_distance": 0.0}))
|
||||
self.assertFalse(jsonLogic(jl_float, {"levenshtein_distance": 2.5}))
|
||||
|
||||
def test_conditions_execution_zero_word_count(self):
|
||||
# Test condition checking for empty page (word_count == 0)
|
||||
self.store.data['watching'][self.watch_uuid].update(
|
||||
{
|
||||
"conditions_match_logic": "ALL",
|
||||
"conditions": [
|
||||
{"operator": "==", "field": "word_count", "value": 0},
|
||||
],
|
||||
}
|
||||
)
|
||||
# Empty text has word_count 0 -> condition should match (True)
|
||||
res_empty = execute_ruleset_against_all_plugins(
|
||||
current_watch_uuid=self.watch_uuid,
|
||||
application_datastruct=self.store.data,
|
||||
ephemeral_data={'text': ""},
|
||||
)
|
||||
self.assertTrue(res_empty.get('result'))
|
||||
|
||||
# Non-empty text has word_count > 0 -> condition should not match (False)
|
||||
res_nonempty = execute_ruleset_against_all_plugins(
|
||||
current_watch_uuid=self.watch_uuid,
|
||||
application_datastruct=self.store.data,
|
||||
ephemeral_data={'text': "Hello world"},
|
||||
)
|
||||
self.assertFalse(res_nonempty.get('result'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Tests for the shared "may the server fetch this URL?" gate.
|
||||
|
||||
# run from dir above changedetectionio/ dir
|
||||
# python3 -m unittest changedetectionio.tests.unit.test_fetch_url_gate
|
||||
|
||||
Every server-side fetch entry point routes through validate_url.is_fetch_url_allowed(). Before it
|
||||
existed, the file:// and private-IP rules were enforced inline in call_browser() only, so any fetch
|
||||
path that did not go through call_browser() was unprotected:
|
||||
|
||||
* a "Goto URL" browser step could read file:///etc/passwd (GHSA-hm22-wg2m-35v4)
|
||||
* /add-watch-ui/snapshot url= could fetch internal hosts (GHSA-56fq-63vj-9992)
|
||||
|
||||
These tests pin the gate's rules AND the browser-step choke point, so a future fetch path that
|
||||
forgets to call the gate is the only way to regress it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from changedetectionio.browser_steps.browser_steps import steppable_browser_interface
|
||||
from changedetectionio.validate_url import (
|
||||
is_fetch_url_allowed,
|
||||
is_special_purpose_ip,
|
||||
validate_fetch_url,
|
||||
validate_fetch_url_async,
|
||||
)
|
||||
|
||||
# tests/conftest.py sets ALLOW_IANA_RESTRICTED_ADDRESSES=true for the functional suite, so the
|
||||
# locked-down default has to be re-asserted explicitly rather than assumed.
|
||||
LOCKED_DOWN = {'ALLOW_IANA_RESTRICTED_ADDRESSES': 'false', 'ALLOW_FILE_URI': 'false'}
|
||||
OPTED_IN = {'ALLOW_IANA_RESTRICTED_ADDRESSES': 'true', 'ALLOW_FILE_URI': 'true'}
|
||||
|
||||
|
||||
class TestFetchUrlGate(unittest.TestCase):
|
||||
|
||||
def assertBlocked(self, url):
|
||||
ok, reason = is_fetch_url_allowed(url)
|
||||
self.assertFalse(ok, f"URL '{url}' should have been blocked")
|
||||
self.assertTrue(reason, f"URL '{url}' was blocked without a reason to show the user")
|
||||
|
||||
def assertAllowed(self, url):
|
||||
ok, reason = is_fetch_url_allowed(url)
|
||||
self.assertTrue(ok, f"URL '{url}' should have been allowed, got: {reason}")
|
||||
|
||||
def test_file_uri_blocked_by_default(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
# All the spellings that reach the same local file
|
||||
for url in ('file:///etc/passwd', 'FILE:///etc/passwd', 'file:/etc/passwd', 'file://etc/passwd'):
|
||||
with self.subTest(url=url):
|
||||
self.assertBlocked(url)
|
||||
|
||||
def test_file_uri_allowed_when_operator_opts_in(self):
|
||||
with patch.dict('os.environ', OPTED_IN):
|
||||
self.assertAllowed('file:///etc/passwd')
|
||||
|
||||
def test_file_uri_blocked_even_if_safe_protocol_regex_was_loosened(self):
|
||||
"""An operator who widens SAFE_PROTOCOL_REGEX for some other scheme must not get local
|
||||
file reads thrown in for free - hence the explicit file: check ahead of is_safe_valid_url()."""
|
||||
env = dict(LOCKED_DOWN, SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):')
|
||||
with patch.dict('os.environ', env):
|
||||
self.assertBlocked('file:///etc/passwd')
|
||||
|
||||
def test_private_and_reserved_addresses_blocked_by_default(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
for url in ('http://127.0.0.1:5000/',
|
||||
'http://localhost/',
|
||||
'http://169.254.169.254/latest/meta-data/', # cloud metadata
|
||||
'http://192.168.1.1/',
|
||||
'http://10.0.0.1/',
|
||||
'http://[::1]/'):
|
||||
with self.subTest(url=url):
|
||||
self.assertBlocked(url)
|
||||
|
||||
def test_cgnat_and_other_non_global_addresses_blocked_by_default(self):
|
||||
"""GHSA-gwph-fp79-379w - the 0.54.1 predicate only tested is_private/is_loopback/
|
||||
is_link_local/is_reserved, none of which are True for RFC 6598 CGNAT space, so
|
||||
100.64.0.0/10 (an ISP's other subscribers, CPE admin panels, CGNAT gateways) stayed
|
||||
fetchable. These are IP literals, so no DNS is involved and CI cannot flake."""
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
for url in ('http://100.64.0.1/', # RFC 6598 CGNAT, first usable
|
||||
'http://100.127.255.254/', # RFC 6598 CGNAT, last usable
|
||||
'http://100.100.100.100/', # inside CGNAT (Alibaba Cloud metadata)
|
||||
'http://192.88.99.1/', # RFC 7526 deprecated 6to4 relay anycast
|
||||
'http://224.0.0.1/', # IPv4 multicast all-hosts
|
||||
'http://[ff02::1]/'): # IPv6 multicast all-nodes
|
||||
with self.subTest(url=url):
|
||||
self.assertBlocked(url)
|
||||
|
||||
def test_cgnat_allowed_when_operator_opts_in(self):
|
||||
"""CGNAT is legitimate for operators monitoring their own carrier network, so the
|
||||
opt-in has to release it the same way it releases 127.0.0.1."""
|
||||
with patch.dict('os.environ', OPTED_IN):
|
||||
self.assertAllowed('http://100.64.0.1/')
|
||||
|
||||
def test_special_purpose_ip_classification(self):
|
||||
"""The predicate itself, without DNS - one place to pin what is and is not fetchable."""
|
||||
for ip in ('100.64.0.1', '100.127.255.254', '192.88.99.1', '224.0.0.1', 'ff02::1',
|
||||
'127.0.0.1', '10.0.0.1', '169.254.169.254', '192.168.1.1', '::1',
|
||||
'0.0.0.0', '255.255.255.255', '198.18.0.1', 'fc00::1', 'fe80::1',
|
||||
'::ffff:100.64.0.1', # CGNAT wrapped as an IPv4-mapped IPv6 address
|
||||
'2002:6440:1::'): # CGNAT wrapped as a 6to4 address
|
||||
with self.subTest(ip=ip):
|
||||
blocked, why = is_special_purpose_ip(ip)
|
||||
self.assertTrue(blocked, f"{ip} should be refused")
|
||||
self.assertTrue(why, f"{ip} was refused without a stated reason")
|
||||
|
||||
for ip in ('1.1.1.1', '8.8.8.8', '93.184.216.34', '2606:4700:4700::1111'):
|
||||
with self.subTest(ip=ip):
|
||||
blocked, why = is_special_purpose_ip(ip)
|
||||
self.assertFalse(blocked, f"public address {ip} was refused as '{why}'")
|
||||
|
||||
def test_cgnat_boundaries_are_exact(self):
|
||||
"""100.64.0.0/10 ends at 100.127.255.255 - 100.63.x and 100.128.x are ordinary public
|
||||
space and must not be collateral damage from a /8-sized over-block."""
|
||||
for ip in ('100.63.255.255', '100.128.0.0'):
|
||||
with self.subTest(ip=ip):
|
||||
blocked, why = is_special_purpose_ip(ip)
|
||||
self.assertFalse(blocked, f"public address {ip} was refused as '{why}'")
|
||||
|
||||
def test_private_addresses_allowed_when_operator_opts_in(self):
|
||||
with patch.dict('os.environ', OPTED_IN):
|
||||
self.assertAllowed('http://127.0.0.1:5000/')
|
||||
|
||||
def test_source_prefix_is_stripped_before_the_hostname_check(self):
|
||||
"""Load-bearing, not cosmetic: urlparse('source:http://127.0.0.1/') reports NO hostname,
|
||||
so leaving the prefix on would hand the private-IP check nothing to look at and let it pass."""
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
self.assertBlocked('source:http://127.0.0.1/')
|
||||
self.assertBlocked('SOURCE:http://169.254.169.254/')
|
||||
self.assertBlocked('source:file:///etc/passwd')
|
||||
|
||||
def test_jinja2_is_rendered_before_the_hostname_check(self):
|
||||
"""The fetch uses the rendered URL, so the rendered URL is what must be judged - otherwise
|
||||
a template expression hides the real target from the check."""
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
self.assertBlocked("http://{{ '127.0.0.1' }}/")
|
||||
self.assertBlocked("http://{% if 1 %}127.0.0.1{% endif %}/")
|
||||
|
||||
def test_parser_differential_payload_always_rejected(self):
|
||||
"""GHSA-rph4-96w6-q594: urlparse sees PUBLIC, urllib3 connects to INTERNAL. A backslash has
|
||||
no legitimate use in a URL, so this is refused even with both opt-ins enabled."""
|
||||
for env in (LOCKED_DOWN, OPTED_IN):
|
||||
with self.subTest(env=env), patch.dict('os.environ', env):
|
||||
self.assertBlocked('http://127.0.0.1:8888\\@example.com/')
|
||||
|
||||
def test_unsupported_schemes_rejected(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
for url in ('javascript:alert(1)', 'data:text/html,<h1>x', 'chrome://version'):
|
||||
with self.subTest(url=url):
|
||||
self.assertBlocked(url)
|
||||
|
||||
def test_empty_input_rejected(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
for url in ('', ' ', None):
|
||||
with self.subTest(url=url):
|
||||
self.assertBlocked(url)
|
||||
|
||||
def test_ordinary_public_urls_still_allowed(self):
|
||||
# Unresolvable hostnames are allowed by design (DNS may be down, domain not yet live), so
|
||||
# these pass with or without working DNS in CI.
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
for url in ('https://example.com/',
|
||||
'source:https://example.com/',
|
||||
'https://example.com/path?a=b&c=d#frag'):
|
||||
with self.subTest(url=url):
|
||||
self.assertAllowed(url)
|
||||
|
||||
def test_validate_fetch_url_raises_with_the_reason(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_fetch_url('file:///etc/passwd')
|
||||
validate_fetch_url('https://example.com/') # must not raise
|
||||
|
||||
def test_validate_fetch_url_async_raises_with_the_reason(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(validate_fetch_url_async('http://127.0.0.1/'))
|
||||
asyncio.run(validate_fetch_url_async('https://example.com/')) # must not raise
|
||||
|
||||
|
||||
class _RecordingPage:
|
||||
"""Stands in for the Playwright page so we can assert navigation never happened."""
|
||||
|
||||
def __init__(self):
|
||||
self.goto_calls = []
|
||||
|
||||
async def goto(self, url, **kwargs):
|
||||
self.goto_calls.append(url)
|
||||
return None
|
||||
|
||||
async def wait_for_timeout(self, ms):
|
||||
return None
|
||||
|
||||
|
||||
class TestBrowserStepGotoUrlGate(unittest.TestCase):
|
||||
"""GHSA-hm22-wg2m-35v4 - browser step values are raw user input and were never validated.
|
||||
|
||||
action_goto_url() is the single choke point for every navigation we initiate (the "Goto URL"
|
||||
step, "Goto site", the live Browser Steps UI and the Add Watch preview all land here), so the
|
||||
assertion that matters is that page.goto() is never reached for a refused URL.
|
||||
"""
|
||||
|
||||
def _interface(self, start_url='https://example.com/'):
|
||||
interface = steppable_browser_interface(start_url=start_url)
|
||||
interface.page = _RecordingPage()
|
||||
return interface
|
||||
|
||||
def test_goto_url_step_cannot_read_local_files(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
interface = self._interface()
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(interface.action_goto_url(value='file:///etc/passwd'))
|
||||
self.assertEqual(interface.page.goto_calls, [], "Chromium was navigated to a refused URL")
|
||||
|
||||
def test_goto_url_step_cannot_reach_private_addresses(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
for url in ('http://127.0.0.1:5000/', 'http://169.254.169.254/latest/meta-data/'):
|
||||
with self.subTest(url=url):
|
||||
interface = self._interface()
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(interface.action_goto_url(value=url))
|
||||
self.assertEqual(interface.page.goto_calls, [])
|
||||
|
||||
def test_goto_site_step_validates_the_start_url_too(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
interface = self._interface(start_url='source:http://127.0.0.1/')
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(interface.action_goto_site())
|
||||
self.assertEqual(interface.page.goto_calls, [])
|
||||
|
||||
def test_permitted_url_still_navigates(self):
|
||||
with patch.dict('os.environ', LOCKED_DOWN):
|
||||
interface = self._interface()
|
||||
asyncio.run(interface.action_goto_url(value='https://example.com/'))
|
||||
self.assertEqual(interface.page.goto_calls, ['https://example.com/'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from changedetectionio.llm import client as m
|
||||
from changedetectionio.llm import evaluator as ev
|
||||
|
||||
|
||||
class TestGeminiFlashLiteClientHandling(unittest.TestCase):
|
||||
def _mock_success_response(
|
||||
self, text="test response", total_tokens=50, input_tokens=30, output_tokens=20
|
||||
):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.choices = [
|
||||
MagicMock(message=MagicMock(content=text, parts=None), finish_reason="stop")
|
||||
]
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.total_tokens = total_tokens
|
||||
mock_usage.prompt_tokens = input_tokens
|
||||
mock_usage.completion_tokens = output_tokens
|
||||
mock_resp.usage = mock_usage
|
||||
return mock_resp
|
||||
|
||||
def _bad_request(self, message="Request contains an invalid argument."):
|
||||
import litellm
|
||||
|
||||
return litellm.BadRequestError(
|
||||
message=message,
|
||||
model="gemini/gemini-2.0-flash-lite",
|
||||
llm_provider="gemini",
|
||||
response=MagicMock(status_code=400),
|
||||
)
|
||||
|
||||
def test_flash_lite_model_omits_temperature_initially(self):
|
||||
mock_resp = self._mock_success_response()
|
||||
with patch("litellm.completion", return_value=mock_resp) as mock_call:
|
||||
text, total_tok, in_tok, out_tok = m.completion(
|
||||
model="gemini/gemini-2.0-flash-lite",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
self.assertEqual(text, "test response")
|
||||
self.assertEqual(total_tok, 50)
|
||||
self.assertEqual(in_tok, 30)
|
||||
self.assertEqual(out_tok, 20)
|
||||
|
||||
kwargs = mock_call.call_args.kwargs
|
||||
self.assertNotIn("temperature", kwargs)
|
||||
|
||||
def test_standard_model_includes_temperature_zero(self):
|
||||
mock_resp = self._mock_success_response()
|
||||
with patch("litellm.completion", return_value=mock_resp) as mock_call:
|
||||
m.completion(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
kwargs = mock_call.call_args.kwargs
|
||||
self.assertEqual(kwargs.get("temperature"), 0)
|
||||
|
||||
def test_bad_request_strips_temperature_and_extra_body_and_retries(self):
|
||||
exc = self._bad_request()
|
||||
mock_resp = self._mock_success_response()
|
||||
with patch("litellm.completion", side_effect=[exc, mock_resp]) as mock_call:
|
||||
text, total_tok, in_tok, out_tok = m.completion(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
extra_body={"generationConfig": {"thinkingConfig": {"thinkingBudget": 0}}},
|
||||
)
|
||||
self.assertEqual(text, "test response")
|
||||
self.assertEqual(mock_call.call_count, 2)
|
||||
|
||||
first_call_kwargs = mock_call.call_args_list[0].kwargs
|
||||
second_call_kwargs = mock_call.call_args_list[1].kwargs
|
||||
|
||||
self.assertIn("temperature", first_call_kwargs)
|
||||
self.assertIn("extra_body", first_call_kwargs)
|
||||
self.assertNotIn("temperature", second_call_kwargs)
|
||||
self.assertNotIn("extra_body", second_call_kwargs)
|
||||
|
||||
def test_does_not_infinite_loop_when_nothing_left_to_strip(self):
|
||||
exc = self._bad_request("another 400 error")
|
||||
with patch("litellm.completion", side_effect=exc) as mock_call:
|
||||
with self.assertRaises(type(exc)):
|
||||
m.completion(
|
||||
model="gemini/gemini-2.0-flash-lite",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
self.assertEqual(mock_call.call_count, 1)
|
||||
|
||||
def test_only_retries_once_on_persistent_400(self):
|
||||
exc1 = self._bad_request("first 400")
|
||||
exc2 = self._bad_request("second 400")
|
||||
with patch("litellm.completion", side_effect=[exc1, exc2]) as mock_call:
|
||||
with self.assertRaises(type(exc2)):
|
||||
m.completion(
|
||||
model="gemini/gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
extra_body={"generationConfig": {"thinkingConfig": {"thinkingBudget": 0}}},
|
||||
)
|
||||
self.assertEqual(mock_call.call_count, 2)
|
||||
|
||||
|
||||
class TestThinkingExtraBodyFlashLite(unittest.TestCase):
|
||||
def test_flash_lite_model_gets_no_thinking_config(self):
|
||||
self.assertIsNone(ev._thinking_extra_body("gemini/gemini-2.0-flash-lite", budget=0))
|
||||
self.assertIsNone(ev._thinking_extra_body("gemini/gemini-3.5-flash-lite", budget=0))
|
||||
|
||||
def test_non_gemini_model_gets_no_thinking_config(self):
|
||||
self.assertIsNone(ev._thinking_extra_body("gpt-4o-mini", budget=100))
|
||||
|
||||
@patch("litellm.get_model_info")
|
||||
def test_gemini_supporting_reasoning_gets_thinking_config(self, mock_info):
|
||||
mock_info.return_value = {"supports_reasoning": True}
|
||||
result = ev._thinking_extra_body("gemini/gemini-2.5-flash", budget=512)
|
||||
self.assertEqual(result, {"generationConfig": {"thinkingConfig": {"thinkingBudget": 512}}})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Regression tests for the scheduler's default-timezone resolution.
|
||||
|
||||
The bug: model/App.py initialises 'scheduler_timezone_default' to None, so the
|
||||
key ALWAYS EXISTS with a None value. Call sites used
|
||||
|
||||
settings['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC'))
|
||||
|
||||
which never reaches its fallback, because dict.get() only substitutes the
|
||||
default when the key is ABSENT - not when its value is None. The resulting None
|
||||
flowed into is_within_schedule(), which does `tz_name.strip()` and raised
|
||||
AttributeError. That took out the edit page (HTTP 500) and, worse, the ticker
|
||||
thread (which did `return False` inside its main loop and stopped scheduling
|
||||
every watch until restart).
|
||||
|
||||
Run: python3 -m unittest changedetectionio.tests.unit.test_scheduler_timezone_resolution
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from changedetectionio.time_handler import default_timezone_name, is_within_schedule
|
||||
|
||||
|
||||
class TestDefaultTimezoneName(unittest.TestCase):
|
||||
|
||||
def test_none_falls_back_to_utc(self):
|
||||
"""The exact bug: a present-but-None setting must not stay None."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop('TZ', None)
|
||||
self.assertEqual(default_timezone_name(None), 'UTC')
|
||||
|
||||
def test_empty_string_falls_back(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop('TZ', None)
|
||||
self.assertEqual(default_timezone_name(''), 'UTC')
|
||||
self.assertEqual(default_timezone_name(' '), 'UTC')
|
||||
|
||||
def test_configured_value_wins(self):
|
||||
self.assertEqual(default_timezone_name('Pacific/Kiritimati'), 'Pacific/Kiritimati')
|
||||
|
||||
def test_configured_value_is_stripped(self):
|
||||
self.assertEqual(default_timezone_name(' Europe/Berlin '), 'Europe/Berlin')
|
||||
|
||||
def test_tz_env_used_when_unconfigured(self):
|
||||
with patch.dict(os.environ, {'TZ': 'Asia/Tokyo'}):
|
||||
self.assertEqual(default_timezone_name(None), 'Asia/Tokyo')
|
||||
|
||||
def test_configured_beats_tz_env(self):
|
||||
with patch.dict(os.environ, {'TZ': 'Asia/Tokyo'}):
|
||||
self.assertEqual(default_timezone_name('Europe/Berlin'), 'Europe/Berlin')
|
||||
|
||||
def test_never_returns_falsy(self):
|
||||
with patch.dict(os.environ, {'TZ': ' '}):
|
||||
self.assertEqual(default_timezone_name(None), 'UTC')
|
||||
for value in (None, '', ' ', 0, False, []):
|
||||
self.assertTrue(default_timezone_name(value),
|
||||
f"default_timezone_name({value!r}) must never be falsy")
|
||||
|
||||
def test_reproduces_the_original_crash(self):
|
||||
"""Passing the un-resolved None straight through still raises - proving
|
||||
the resolver is what prevents it, not luck elsewhere."""
|
||||
schedule = {
|
||||
'enabled': True,
|
||||
'monday': {'enabled': True, 'start_time': '00:00',
|
||||
'duration': {'hours': '24', 'minutes': '00'}},
|
||||
}
|
||||
with self.assertRaises(AttributeError):
|
||||
is_within_schedule(time_schedule_limit=schedule, default_tz=None)
|
||||
|
||||
# ...and does not raise once resolved
|
||||
full = {'enabled': True}
|
||||
for day in ('monday', 'tuesday', 'wednesday', 'thursday',
|
||||
'friday', 'saturday', 'sunday'):
|
||||
full[day] = {'enabled': True, 'start_time': '00:00',
|
||||
'duration': {'hours': '24', 'minutes': '00'}}
|
||||
self.assertTrue(
|
||||
is_within_schedule(time_schedule_limit=full,
|
||||
default_tz=default_timezone_name(None))
|
||||
)
|
||||
|
||||
|
||||
class TestApiTimezoneValidation(unittest.TestCase):
|
||||
"""
|
||||
The edit form runs validateTimeZoneName on time_schedule_limit.timezone, but
|
||||
the API did not: the OpenAPI schema declared no `timezone` property and did
|
||||
not set additionalProperties:false, so any string was accepted and stored.
|
||||
"""
|
||||
|
||||
DAYS = ('monday', 'tuesday', 'wednesday', 'thursday',
|
||||
'friday', 'saturday', 'sunday')
|
||||
|
||||
def _payload(self, timezone=..., ):
|
||||
sched = {'enabled': True}
|
||||
for day in self.DAYS:
|
||||
sched[day] = {'enabled': True, 'start_time': '00:00',
|
||||
'duration': {'hours': '24', 'minutes': '00'}}
|
||||
if timezone is not ...:
|
||||
sched['timezone'] = timezone
|
||||
return {'time_schedule_limit': sched}
|
||||
|
||||
def setUp(self):
|
||||
from changedetectionio.api.Watch import validate_time_schedule_limit
|
||||
self.validate = validate_time_schedule_limit
|
||||
|
||||
def test_valid_zones_accepted(self):
|
||||
for tz in ('UTC', 'Europe/Berlin', 'America/Los_Angeles', 'Pacific/Kiritimati'):
|
||||
self.assertIsNone(self.validate(self._payload(tz)), f"{tz} should be valid")
|
||||
|
||||
def test_absent_or_empty_timezone_is_fine(self):
|
||||
self.assertIsNone(self.validate(self._payload())) # key absent
|
||||
self.assertIsNone(self.validate(self._payload(''))) # empty
|
||||
self.assertIsNone(self.validate(self._payload(None))) # explicit null
|
||||
|
||||
def test_unknown_zone_rejected(self):
|
||||
err = self.validate(self._payload('Not/ARealZone'))
|
||||
self.assertIsNotNone(err)
|
||||
self.assertIn('Not/ARealZone', err)
|
||||
self.assertIn('not a valid timezone', err.lower())
|
||||
|
||||
def test_case_sensitive_like_the_form(self):
|
||||
"""'utc' is not an IANA name; the form rejects it, so must the API."""
|
||||
self.assertIsNotNone(self.validate(self._payload('utc')))
|
||||
|
||||
def test_non_string_rejected(self):
|
||||
for bad in (123, 12.5, True, ['UTC'], {'name': 'UTC'}):
|
||||
self.assertIsNotNone(self.validate(self._payload(bad)),
|
||||
f"{bad!r} should be rejected")
|
||||
|
||||
def test_no_schedule_key_at_all(self):
|
||||
self.assertIsNone(self.validate({}))
|
||||
self.assertIsNone(self.validate({'time_schedule_limit': None}))
|
||||
self.assertIsNone(self.validate({'url': 'https://example.com'}))
|
||||
|
||||
def test_non_dict_schedule_is_left_to_openapi(self):
|
||||
"""Structural type errors are the OpenAPI layer's job, not ours."""
|
||||
self.assertIsNone(self.validate({'time_schedule_limit': 'nope'}))
|
||||
self.assertIsNone(self.validate({'time_schedule_limit': []}))
|
||||
|
||||
def test_rejected_zone_would_have_crashed_the_scheduler(self):
|
||||
"""Tie the validation back to the actual failure it prevents."""
|
||||
payload = self._payload('Not/ARealZone')
|
||||
self.assertIsNotNone(self.validate(payload))
|
||||
with self.assertRaises(Exception):
|
||||
is_within_schedule(time_schedule_limit=payload['time_schedule_limit'],
|
||||
default_tz='UTC')
|
||||
|
||||
|
||||
class TestApiSpecDocumentsTimezone(unittest.TestCase):
|
||||
"""The field is real and settable; the published contract must say so."""
|
||||
|
||||
def test_timezone_is_declared_in_the_openapi_spec(self):
|
||||
import os
|
||||
import yaml
|
||||
|
||||
here = os.path.dirname(__file__)
|
||||
spec_path = os.path.join(here, '..', '..', '..', 'docs', 'api-spec.yaml')
|
||||
if not os.path.isfile(spec_path):
|
||||
self.skipTest("api-spec.yaml not present in this layout")
|
||||
|
||||
with open(spec_path) as f:
|
||||
spec = yaml.safe_load(f)
|
||||
|
||||
schedule = spec['components']['schemas']['WatchBase']['properties']['time_schedule_limit']
|
||||
self.assertIn('timezone', schedule['properties'],
|
||||
"time_schedule_limit.timezone is settable via the form and the "
|
||||
"API but is missing from the OpenAPI spec")
|
||||
self.assertEqual(schedule['properties']['timezone']['type'], 'string')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Tests for thread_supervisor.supervise().
|
||||
|
||||
Context: the ticker thread had `return False` buried inside its per-watch loop.
|
||||
One watch with an unresolvable timezone made the whole loop return, the thread
|
||||
ended, and no watch was ever checked again - with the process still up and
|
||||
looking healthy. threading.Thread cannot be restarted (start() twice raises
|
||||
RuntimeError), so recovery has to live inside the thread.
|
||||
|
||||
The two behaviours that matter:
|
||||
- a target that returns or raises is restarted, loudly
|
||||
- a target that exits during shutdown is NOT restarted
|
||||
|
||||
Run: python3 -m unittest changedetectionio.tests.unit.test_thread_supervisor
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from changedetectionio.thread_supervisor import supervise, start_supervised_thread
|
||||
|
||||
|
||||
class TestRestartsOnDeath(unittest.TestCase):
|
||||
|
||||
def test_target_that_returns_is_restarted(self):
|
||||
"""The exact ticker bug: a stray `return` must not end the thread."""
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
|
||||
def flaky():
|
||||
calls.append('run')
|
||||
if len(calls) >= 3:
|
||||
exit_event.set() # let the supervisor finish
|
||||
return # simulates `return False` in the loop
|
||||
|
||||
supervise(flaky, 'flaky', exit_event, max_backoff=0.01, healthy_after=9999)
|
||||
self.assertEqual(len(calls), 3, "target should have been restarted twice")
|
||||
|
||||
def test_target_that_raises_is_restarted(self):
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
|
||||
def crashy():
|
||||
calls.append('run')
|
||||
if len(calls) >= 3:
|
||||
exit_event.set()
|
||||
return
|
||||
raise RuntimeError("boom")
|
||||
|
||||
supervise(crashy, 'crashy', exit_event, max_backoff=0.01, healthy_after=9999)
|
||||
self.assertEqual(len(calls), 3)
|
||||
|
||||
def test_restart_reason_is_reported(self):
|
||||
exit_event = threading.Event()
|
||||
reasons = []
|
||||
calls = []
|
||||
|
||||
def mixed():
|
||||
calls.append('run')
|
||||
if len(calls) == 1:
|
||||
return # clean but wrong
|
||||
if len(calls) == 2:
|
||||
raise ValueError("nope") # crash
|
||||
exit_event.set()
|
||||
|
||||
supervise(mixed, 'mixed', exit_event, max_backoff=0.01, healthy_after=9999,
|
||||
_on_restart=lambda n, reason: reasons.append(reason))
|
||||
|
||||
self.assertEqual(len(reasons), 2)
|
||||
self.assertIn('returned unexpectedly', reasons[0])
|
||||
self.assertIn('ValueError: nope', reasons[1])
|
||||
|
||||
def test_backoff_grows_then_resets_after_healthy_run(self):
|
||||
exit_event = threading.Event()
|
||||
stamps = []
|
||||
|
||||
def failing():
|
||||
stamps.append(time.monotonic())
|
||||
if len(stamps) >= 4:
|
||||
exit_event.set()
|
||||
return
|
||||
raise RuntimeError("boom")
|
||||
|
||||
supervise(failing, 'failing', exit_event, max_backoff=0.08, healthy_after=9999)
|
||||
gaps = [stamps[i + 1] - stamps[i] for i in range(len(stamps) - 1)]
|
||||
# 1s doubling is capped at max_backoff, so every gap should hit the cap
|
||||
for gap in gaps:
|
||||
self.assertGreaterEqual(gap, 0.05, f"gaps={gaps} - backoff not applied")
|
||||
|
||||
def test_does_not_spin_hot(self):
|
||||
"""A target failing instantly must not be restarted in a tight loop."""
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
start = time.monotonic()
|
||||
|
||||
def instant():
|
||||
calls.append(1)
|
||||
if len(calls) >= 3:
|
||||
exit_event.set()
|
||||
return
|
||||
raise RuntimeError("instant")
|
||||
|
||||
supervise(instant, 'instant', exit_event, max_backoff=0.05, healthy_after=9999)
|
||||
self.assertGreater(time.monotonic() - start, 0.05,
|
||||
"supervisor restarted with no delay - would burn CPU")
|
||||
|
||||
|
||||
class TestDoesNotRestartDuringShutdown(unittest.TestCase):
|
||||
|
||||
def test_exit_event_stops_restarting(self):
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
|
||||
def target():
|
||||
calls.append('run')
|
||||
exit_event.set() # shutdown requested, then return normally
|
||||
|
||||
supervise(target, 'target', exit_event, max_backoff=0.01)
|
||||
self.assertEqual(len(calls), 1, "must not restart once exit_event is set")
|
||||
|
||||
def test_exit_event_set_before_start_never_runs_target(self):
|
||||
exit_event = threading.Event()
|
||||
exit_event.set()
|
||||
calls = []
|
||||
supervise(lambda: calls.append('run'), 'target', exit_event)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_secondary_shutdown_flag_stops_restarting(self):
|
||||
"""sigshutdown_handler also sets datastore.stop_thread - honour it."""
|
||||
exit_event = threading.Event()
|
||||
state = {'stop_thread': False}
|
||||
calls = []
|
||||
|
||||
def target():
|
||||
calls.append('run')
|
||||
state['stop_thread'] = True # only the secondary flag is set
|
||||
return
|
||||
|
||||
supervise(target, 'target', exit_event,
|
||||
is_shutting_down=lambda: state['stop_thread'],
|
||||
max_backoff=0.01)
|
||||
self.assertEqual(len(calls), 1,
|
||||
"must not restart when the secondary shutdown flag is set")
|
||||
self.assertFalse(exit_event.is_set(), "primary flag was never set in this test")
|
||||
|
||||
def test_raising_shutdown_predicate_does_not_block_restart(self):
|
||||
"""A broken predicate must not silently disable recovery."""
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
|
||||
def target():
|
||||
calls.append('run')
|
||||
if len(calls) >= 2:
|
||||
exit_event.set()
|
||||
return
|
||||
|
||||
def broken():
|
||||
raise RuntimeError("predicate is broken")
|
||||
|
||||
supervise(target, 'target', exit_event, is_shutting_down=broken,
|
||||
max_backoff=0.01, healthy_after=9999)
|
||||
self.assertEqual(len(calls), 2, "should still have restarted once")
|
||||
|
||||
|
||||
class TestStartSupervisedThread(unittest.TestCase):
|
||||
|
||||
def test_returns_a_real_thread_object(self):
|
||||
"""Thread(...).start() returns None - that is why ticker_thread was
|
||||
always None and nothing could check is_alive()."""
|
||||
exit_event = threading.Event()
|
||||
started = threading.Event()
|
||||
|
||||
def target():
|
||||
started.set()
|
||||
exit_event.wait(5)
|
||||
|
||||
t = start_supervised_thread(target, 'TestThread', exit_event)
|
||||
self.addCleanup(exit_event.set)
|
||||
|
||||
self.assertIsInstance(t, threading.Thread)
|
||||
self.assertTrue(started.wait(5), "target did not run")
|
||||
self.assertTrue(t.is_alive())
|
||||
self.assertTrue(t.daemon)
|
||||
self.assertEqual(t.name, 'TestThread')
|
||||
|
||||
exit_event.set()
|
||||
t.join(timeout=5)
|
||||
self.assertFalse(t.is_alive(), "thread did not exit on shutdown")
|
||||
|
||||
def test_thread_survives_a_dying_target(self):
|
||||
"""End to end: the thread outlives a target that keeps returning."""
|
||||
exit_event = threading.Event()
|
||||
calls = []
|
||||
done = threading.Event()
|
||||
|
||||
def target():
|
||||
calls.append(1)
|
||||
if len(calls) >= 3:
|
||||
done.set()
|
||||
exit_event.wait(5)
|
||||
return
|
||||
|
||||
t = start_supervised_thread(target, 'Dying', exit_event, max_backoff=0.01,
|
||||
healthy_after=9999)
|
||||
self.addCleanup(exit_event.set)
|
||||
|
||||
self.assertTrue(done.wait(10), f"target was not restarted (calls={len(calls)})")
|
||||
self.assertTrue(t.is_alive(), "thread died despite supervision")
|
||||
exit_event.set()
|
||||
t.join(timeout=5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -23,7 +23,7 @@ def test_default_timezone_override_like_safe_jinja(mocker):
|
||||
|
||||
# Mock arrow.now to return a fixed time
|
||||
fixed_time = arrow.Arrow(2025, 1, 15, 12, 0, 0, tzinfo='America/New_York')
|
||||
mock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
|
||||
mock = mocker.patch("arrow.now", return_value=fixed_time)
|
||||
|
||||
# Use empty string timezone - should use the overridden default
|
||||
template_str = "{% now '' %}"
|
||||
@@ -46,7 +46,7 @@ def test_default_timezone_not_overridden(mocker):
|
||||
|
||||
# Mock arrow.now
|
||||
fixed_time = arrow.Arrow(2025, 1, 15, 17, 0, 0, tzinfo='UTC')
|
||||
mock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
|
||||
mock = mocker.patch("arrow.now", return_value=fixed_time)
|
||||
|
||||
# Use empty string timezone - should use 'UTC' default
|
||||
template_str = "{% now '' %}"
|
||||
@@ -69,7 +69,7 @@ def test_datetime_format_override_like_safe_jinja(mocker):
|
||||
|
||||
# Mock arrow.now
|
||||
fixed_time = arrow.Arrow(2025, 1, 15, 14, 30, 45, tzinfo='UTC')
|
||||
mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
|
||||
mocker.patch("arrow.now", return_value=fixed_time)
|
||||
|
||||
# Don't specify format - should use overridden default
|
||||
template_str = "{% now 'UTC' %}"
|
||||
@@ -89,7 +89,7 @@ def test_offset_with_overridden_timezone(mocker):
|
||||
jinja2_env.default_timezone = 'Europe/London'
|
||||
|
||||
fixed_time = arrow.Arrow(2025, 1, 15, 10, 0, 0, tzinfo='Europe/London')
|
||||
mock = mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
|
||||
mock = mocker.patch("arrow.now", return_value=fixed_time)
|
||||
|
||||
# Use offset with empty timezone string
|
||||
template_str = "{% now '' + 'hours=2', '%Y-%m-%d %H:%M:%S' %}"
|
||||
@@ -110,7 +110,7 @@ def test_weekday_parameter_converted_to_int(mocker):
|
||||
|
||||
# Wednesday, Jan 15, 2025
|
||||
fixed_time = arrow.Arrow(2025, 1, 15, 12, 0, 0, tzinfo='UTC')
|
||||
mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
|
||||
mocker.patch("arrow.now", return_value=fixed_time)
|
||||
|
||||
# Add offset to next Monday (weekday=0)
|
||||
template_str = "{% now 'UTC' + 'weekday=0', '%A' %}"
|
||||
@@ -127,7 +127,7 @@ def test_multiple_offset_parameters(mocker):
|
||||
jinja2_env = ImmutableSandboxedEnvironment(extensions=[TimeExtension])
|
||||
|
||||
fixed_time = arrow.Arrow(2025, 1, 15, 10, 30, 45, tzinfo='UTC')
|
||||
mocker.patch("changedetectionio.jinja2_custom.extensions.TimeExtension.arrow.now", return_value=fixed_time)
|
||||
mocker.patch("arrow.now", return_value=fixed_time)
|
||||
|
||||
# Test multiple parameters: days, hours, minutes, seconds
|
||||
template_str = "{% now 'UTC' + 'days=1,hours=2,minutes=15,seconds=10', '%Y-%m-%d %H:%M:%S' %}"
|
||||
|
||||
@@ -205,8 +205,8 @@ def test_browsersteps_edit_UI_startsession(client, live_server, measure_memory_u
|
||||
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'fetch_backend': 'html_webdriver', 'paused': True})
|
||||
|
||||
# Test starting a browsersteps session
|
||||
res = client.get(
|
||||
# Test starting a browsersteps session (POST only - it spins up a real browser)
|
||||
res = client.post(
|
||||
url_for("browser_steps.browsersteps_start_session", uuid=uuid),
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Keep critical background threads alive.
|
||||
|
||||
A threading.Thread cannot be restarted. Once its target returns, the thread is
|
||||
finished and calling start() again raises RuntimeError("threads can only be
|
||||
started once"). There is no built-in "respawn on death" option. So recovery has
|
||||
to be built into the target itself.
|
||||
|
||||
This matters because a background loop can die in two ways that look identical
|
||||
from outside, and both are silent:
|
||||
|
||||
- it raises, and the traceback goes nowhere because nobody joins a daemon thread
|
||||
- it simply `return`s, e.g. a stray `return False` deep inside the loop body
|
||||
|
||||
The second is how the ticker thread stopped scheduling every watch in the app:
|
||||
one watch with an unresolvable timezone hit `return False`, the whole loop
|
||||
exited, and no watch was ever checked again until the process restarted. Nothing
|
||||
was logged above ERROR, and the process stayed up and healthy-looking.
|
||||
|
||||
supervise() wraps a target so that any exit which is NOT a real shutdown is
|
||||
logged CRITICAL and the target is re-entered, with exponential backoff so a
|
||||
target that fails instantly cannot spin the CPU.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Back off 1, 2, 4 ... up to this many seconds between restarts.
|
||||
DEFAULT_MAX_BACKOFF_SECONDS = 60
|
||||
|
||||
# A target that stayed up at least this long is considered to have recovered,
|
||||
# so its backoff resets rather than continuing to grow.
|
||||
HEALTHY_RUNTIME_SECONDS = 60
|
||||
|
||||
|
||||
def supervise(target, name, exit_event, is_shutting_down=None,
|
||||
max_backoff=DEFAULT_MAX_BACKOFF_SECONDS,
|
||||
healthy_after=HEALTHY_RUNTIME_SECONDS,
|
||||
_on_restart=None):
|
||||
"""
|
||||
Run `target` forever, restarting it if it ever returns or raises.
|
||||
|
||||
Never restarts once shutdown has begun - an exiting target is expected then,
|
||||
and respawning it would fight the shutdown path and delay process exit.
|
||||
|
||||
Args:
|
||||
target: zero-arg callable expected to loop until shutdown
|
||||
name: thread name, used in log messages
|
||||
exit_event: threading.Event - the primary shutdown signal
|
||||
is_shutting_down: optional extra zero-arg predicate returning True during
|
||||
shutdown. sigshutdown_handler() sets both app.config.exit
|
||||
and datastore.stop_thread, so pass the latter here to be
|
||||
certain a restart can never race a shutdown.
|
||||
max_backoff: ceiling for the restart delay, in seconds
|
||||
healthy_after: runtime after which the backoff resets to 1s
|
||||
_on_restart: test hook, called with (restart_count, reason) before each retry
|
||||
|
||||
Returns:
|
||||
None - only once shutdown is signalled
|
||||
"""
|
||||
def stopping():
|
||||
if exit_event.is_set():
|
||||
return True
|
||||
if is_shutting_down is not None:
|
||||
try:
|
||||
return bool(is_shutting_down())
|
||||
except Exception:
|
||||
# A broken predicate must not keep a healthy thread from restarting
|
||||
return False
|
||||
return False
|
||||
|
||||
backoff = 1
|
||||
restarts = 0
|
||||
|
||||
while not stopping():
|
||||
started = time.monotonic()
|
||||
reason = None
|
||||
|
||||
try:
|
||||
target()
|
||||
# Falling out of the target is only legitimate during shutdown.
|
||||
if stopping():
|
||||
break
|
||||
reason = "returned unexpectedly (a stray `return` inside its loop?)"
|
||||
logger.critical(
|
||||
f"{name} {reason} - this thread must run until shutdown. Restarting it."
|
||||
)
|
||||
except Exception as e:
|
||||
if stopping():
|
||||
break
|
||||
reason = f"crashed: {type(e).__name__}: {e}"
|
||||
logger.opt(exception=True).critical(f"{name} {reason} - restarting it.")
|
||||
|
||||
ran_for = time.monotonic() - started
|
||||
backoff = 1 if ran_for >= healthy_after else min(backoff * 2, max_backoff)
|
||||
restarts += 1
|
||||
|
||||
if _on_restart:
|
||||
_on_restart(restarts, reason)
|
||||
|
||||
logger.critical(
|
||||
f"{name} restart #{restarts} in {backoff}s (previous run lasted {ran_for:.1f}s)"
|
||||
)
|
||||
# wait() returns True immediately once shutdown is requested
|
||||
if exit_event.wait(backoff) or stopping():
|
||||
break
|
||||
|
||||
logger.info(f"{name} supervisor exiting - shutdown requested "
|
||||
f"(after {restarts} restart(s))")
|
||||
|
||||
|
||||
def start_supervised_thread(target, name, exit_event, is_shutting_down=None, **kwargs):
|
||||
"""
|
||||
Start `target` in a daemon thread wrapped in supervise().
|
||||
|
||||
Returns:
|
||||
threading.Thread: the started thread (unlike Thread(...).start(), which
|
||||
returns None - assigning that to a module global is why nothing could
|
||||
ever check the ticker thread's is_alive()).
|
||||
"""
|
||||
import threading
|
||||
|
||||
thread = threading.Thread(
|
||||
target=supervise,
|
||||
args=(target, name, exit_event),
|
||||
kwargs={'is_shutting_down': is_shutting_down, **kwargs},
|
||||
daemon=True,
|
||||
name=name,
|
||||
)
|
||||
thread.start()
|
||||
return thread
|
||||
@@ -1,9 +1,31 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
import arrow
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
def default_timezone_name(configured=None):
|
||||
"""
|
||||
Resolve the scheduler's default IANA timezone name. Never returns None/''.
|
||||
|
||||
`scheduler_timezone_default` is initialised to None in model/App.py, so the
|
||||
key always EXISTS with a None value. That makes the common idiom
|
||||
|
||||
settings['application'].get('scheduler_timezone_default', os.getenv('TZ', 'UTC'))
|
||||
|
||||
silently return None - dict.get() only falls back when the key is absent,
|
||||
not when its value is None. The None then reaches is_within_schedule(),
|
||||
which does `tz_name.strip()` and raises AttributeError.
|
||||
|
||||
Args:
|
||||
configured: the stored setting value (may be None or empty)
|
||||
|
||||
Returns:
|
||||
str: a non-empty timezone name, falling back to $TZ then 'UTC'
|
||||
"""
|
||||
return (configured or os.getenv('TZ') or 'UTC').strip() or 'UTC'
|
||||
|
||||
|
||||
class Weekday(IntEnum):
|
||||
"""Enumeration for days of the week."""
|
||||
Monday = 0
|
||||
@@ -32,6 +54,7 @@ def am_i_inside_time(
|
||||
Returns:
|
||||
bool: True if the current time is within the time range, False otherwise.
|
||||
"""
|
||||
import arrow
|
||||
# Parse the target day of the week
|
||||
try:
|
||||
target_weekday = Weekday[day_of_week.capitalize()]
|
||||
@@ -91,6 +114,7 @@ def is_within_schedule(time_schedule_limit, default_tz="UTC"):
|
||||
Returns:
|
||||
bool: True if current time is within the schedule, False otherwise.
|
||||
"""
|
||||
import arrow
|
||||
if time_schedule_limit and time_schedule_limit.get('enabled'):
|
||||
# Get the timezone the time schedule is in, so we know what day it is there
|
||||
tz_name = time_schedule_limit.get('timezone')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user