diff --git a/changedetectionio/blueprint/watchlist/__init__.py b/changedetectionio/blueprint/watchlist/__init__.py
index f005fc16..8aaa17ce 100644
--- a/changedetectionio/blueprint/watchlist/__init__.py
+++ b/changedetectionio/blueprint/watchlist/__init__.py
@@ -13,6 +13,7 @@ from changedetectionio.auth_decorator import login_optionally_required
# Shared filtering — the single source of truth, also used by the ui blueprint's
# bulk actions so a filtered view and the actions taken on it always agree.
from changedetectionio.blueprint.watchlist import filters as wl_filters
+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")
@@ -96,8 +97,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
- proxy_list = datastore.proxy_list
-
from changedetectionio import content_fetchers
available_fetchers = content_fetchers.available_fetchers()
@@ -105,14 +104,19 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER
llm_configured = bool(_get_llm_config(datastore))
+ # Everything the row markup itself needs comes from here, shared with the Socket.IO
+ # row push so a live-updated row can't drift from the server-rendered one.
+ row_ctx = watch_row_context(datastore,
+ active_tag_uuid=active_tag_uuid,
+ queued_uuids=update_q.get_queued_uuids())
+
output = render_template(
"watch-overview.html",
+ **row_ctx,
active_tag=active_tag,
- active_tag_uuid=active_tag_uuid,
active_processor=active_processor,
checking_now_size=len(worker_pool.get_running_uuids()),
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
- datastore=datastore,
errored_count=errored_count,
deals_count=deals_count,
unread_count=unread_count,
@@ -122,7 +126,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
generate_tag_colors=processors.generate_processor_badge_colors,
wcag_text_color=processors.wcag_text_color,
guid=datastore.data['app_guid'],
- has_proxies=proxy_list,
available_fetchers=available_fetchers,
#header=_("todo - tag name etc"),
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
@@ -130,16 +133,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
pagination=pagination,
processor_badge_css=processors.get_processor_badge_css(),
processor_badge_texts=processors.get_processor_badge_texts(),
- processor_descriptions=processors.get_processor_descriptions(),
queue_size=update_q.qsize(),
- queued_uuids=update_q.get_queued_uuids(),
# Active view filters (tag/processor/q/unread/...) so links (e.g. column sorting)
# can re-apply them and not drop the operator's current filtered view.
active_filters=wl_filters.filter_query_args(request.args),
search_q=request.args.get('q', '').strip(),
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
- system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
tags=sorted_tags,
unread_changes_count=datastore.unread_changes_count,
watches=sorted_watches,
diff --git a/changedetectionio/blueprint/watchlist/row_context.py b/changedetectionio/blueprint/watchlist/row_context.py
new file mode 100644
index 00000000..461c7f43
--- /dev/null
+++ b/changedetectionio/blueprint/watchlist/row_context.py
@@ -0,0 +1,43 @@
+"""Template context needed to render a single watch list row.
+
+watch-overview-single-row.html is rendered from two places: inline by the watch list page
+(via {% include %} inside the row loop) and standalone when a row is pushed over Socket.IO.
+Both MUST build their context here — if they build it separately the pushed row silently
+drifts from the server-rendered one, which is the bug class this exists to prevent.
+
+Only names the row cannot get for itself belong here. Anything registered app-wide
+(`is_checking_now` template global, `fetcher_status_icons` filter, `url_for`, `_()`) is
+already available in any render and is deliberately NOT listed.
+"""
+
+from changedetectionio import processors
+
+
+def watch_row_context(datastore, active_tag_uuid=None, queued_uuids=None):
+ """Context for one (or many) watch list rows.
+
+ active_tag_uuid matters: the row's Edit/Recheck links carry the tag so the operator lands
+ back on the filtered view they came from. A pushed row must therefore be rendered with the
+ tag of the page that will receive it, not a global one.
+
+ queued_uuids is accepted so a caller rendering many rows can fetch the queue once instead
+ of per row; omit it and the live queue is read for you.
+ """
+ if queued_uuids is None:
+ # Local import: flask_app imports blueprints, so a module-level import would cycle.
+ from changedetectionio.flask_app import update_q
+ queued_uuids = update_q.get_queued_uuids()
+
+ return {
+ 'active_tag_uuid': active_tag_uuid,
+ # Kept 0 (rather than any_watches_have_processor_by_name) while the price column is
+ # disabled — it also decides cols_required on the page, so page and row must agree or
+ # a pushed row ends up with a different
count than the table header.
+ 'any_has_restock_price_processor': 0,
+ 'datastore': datastore,
+ 'has_proxies': datastore.proxy_list,
+ 'processor_descriptions': processors.get_processor_descriptions(),
+ 'queued_uuids': queued_uuids,
+ 'system_default_fetcher': datastore.data['settings']['application'].get('fetch_backend'),
+ 'ui_settings': datastore.data['settings']['application']['ui'],
+ }
diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview-single-row.html b/changedetectionio/blueprint/watchlist/templates/watch-overview-single-row.html
new file mode 100644
index 00000000..9f79325d
--- /dev/null
+++ b/changedetectionio/blueprint/watchlist/templates/watch-overview-single-row.html
@@ -0,0 +1,172 @@
+{#
+ A single watch list
, extracted from watch-overview.html so the same markup can be
+ re-rendered on its own and pushed over Socket.IO (realtime row resync) - one renderer,
+ so the row can never drift from the server-rendered list.
+
+ Rendered via {% include %} from the watch list loop, which inherits the full context:
+ watch, loop, is_checking_now(), queued_uuids, has_proxies, datastore, active_tag_uuid,
+ system_default_fetcher, ui_settings, favicons_enabled, fetcher_status_icons,
+ processor_descriptions, get_all_tags_for_watch(), any_has_restock_price_processor
+#}
+ {%- set checking_now = is_checking_now(watch) -%}
+ {%- set history_n = watch.history_n -%}
+ {%- set favicon = watch.get_favicon_filename() -%}
+ {%- set error_texts = watch.compile_error_texts(has_proxies=has_proxies) -%}
+ {%- set system_use_url_watchlist = datastore.data['settings']['application']['ui'].get('use_page_title_in_list') -%}
+ {# Class settings mirrored in changedetectionio/static/js/realtime.js for the frontend #}
+{# loop.cycle('pure-table-odd', 'pure-table-even'),#}
+ {%- set row_classes = [
+ 'processor-' ~ watch['processor'],
+ 'has-error' if error_texts|length > 2 else '',
+ 'paused' if watch.paused is defined and watch.paused != False else '',
+ 'unviewed' if watch.has_unviewed else '',
+ 'has-restock-info' if watch.has_restock_info else 'no-restock-info',
+ 'has-favicon' if favicon else '',
+ 'in-stock' if watch.has_restock_info and watch['restock']['in_stock'] else '',
+ 'not-in-stock' if watch.has_restock_info and not watch['restock']['in_stock'] else '',
+ 'queued' if watch.uuid in queued_uuids else '',
+ 'checking-now' if checking_now else '',
+ 'notification_muted' if watch.notification_muted else '',
+ 'single-history' if history_n == 1 else '',
+ 'multiple-history' if history_n >= 2 else '',
+ 'use-html-title' if system_use_url_watchlist else 'no-html-title',
+ ] -%}
+
+
{# {{ loop.index+pagination.skip }}#}
+
+
+
+
+
+
+
+
+
+
+
+ {% if 'favicons_enabled' not in ui_settings or ui_settings['favicons_enabled'] %}
+
+
+ {%- 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 }}
+ {%- endif -%}
+ {%- if watch.is_pdf -%}{%- endif -%}
+ {%- if watch.has_browser_steps -%}{%- endif -%}
+
+{%- if watch['processor'] == 'restock_diff' -%}
+ {#- @todo - this could be injected somehow watch.extra_row_info or something -#}
+
+ {%- if watch.has_restock_info -%}
+
+
+ {%- if watch['restock']['in_stock']-%} {{ _('In stock') }} {%- else-%} {{ _('Not in stock') }} {%- endif -%}
+
+ {%- endif -%}
+
+ {%- if watch.get('restock') and watch['restock'].get('price') -%}
+ {%- set restock = watch['restock'] -%}
+ {%- set price = restock.get('price') -%}
+ {%- set currency = restock.get('currency') if restock.get('currency','') else '' -%}
+
+ {%- if price is not none and (price|string)|regex_search('\d') -%}
+
+ {# @todo: make parse_currency/parse_decimal aware of the locale of the actual web page and use that instead changedetectionio/processors/restock_diff/__init__.py #}
+ {%- if price is number -%}{# It's a number so we can convert it to their locale' #}
+ {{ price|format_number_locale }} {{ currency }}
+ {%- else -%}{# It's totally fine if it arrives as something else, the website might be something weird in this field #}
+ {{ price }} {{ currency }}
+ {%- endif -%}
+
+ {%- set price_change_pct = restock.get_price_change_percent() -%}
+ {%- if price_change_pct is not none -%}
+ {%- set prev_price = restock.get('last_price') -%}
+ {%- set prev_disp = (prev_price|format_number_locale ~ ' ' ~ currency) if prev_price is number else prev_price -%}
+ {%- if price_change_pct < 0 -%}
+ ▼ {{ '%g'|format(price_change_pct) }}%
+ {%- else -%}
+ ▲ +{{ '%g'|format(price_change_pct) }}%
+ {%- endif -%}
+ {%- endif -%}
+ {%- endif -%}
+ {%- elif not watch.has_restock_info -%}
+ {{ _('No information') }}
+ {%- endif -%}
+
-
- {%- 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 }}
- {%- endif -%}
- {%- if watch.is_pdf -%}{%- endif -%}
- {%- if watch.has_browser_steps -%}{%- endif -%}
-
-{%- if watch['processor'] == 'restock_diff' -%}
- {#- @todo - this could be injected somehow watch.extra_row_info or something -#}
-
- {%- if watch.has_restock_info -%}
-
-
- {%- if watch['restock']['in_stock']-%} {{ _('In stock') }} {%- else-%} {{ _('Not in stock') }} {%- endif -%}
-
- {%- endif -%}
-
- {%- if watch.get('restock') and watch['restock'].get('price') -%}
- {%- set restock = watch['restock'] -%}
- {%- set price = restock.get('price') -%}
- {%- set currency = restock.get('currency') if restock.get('currency','') else '' -%}
-
- {%- if price is not none and (price|string)|regex_search('\d') -%}
-
- {# @todo: make parse_currency/parse_decimal aware of the locale of the actual web page and use that instead changedetectionio/processors/restock_diff/__init__.py #}
- {%- if price is number -%}{# It's a number so we can convert it to their locale' #}
- {{ price|format_number_locale }} {{ currency }}
- {%- else -%}{# It's totally fine if it arrives as something else, the website might be something weird in this field #}
- {{ price }} {{ currency }}
- {%- endif -%}
-
- {%- set price_change_pct = restock.get_price_change_percent() -%}
- {%- if price_change_pct is not none -%}
- {%- set prev_price = restock.get('last_price') -%}
- {%- set prev_disp = (prev_price|format_number_locale ~ ' ' ~ currency) if prev_price is number else prev_price -%}
- {%- if price_change_pct < 0 -%}
- ▼ {{ '%g'|format(price_change_pct) }}%
- {%- else -%}
- ▲ +{{ '%g'|format(price_change_pct) }}%
- {%- endif -%}
- {%- endif -%}
- {%- endif -%}
- {%- elif not watch.has_restock_info -%}
- {{ _('No information') }}
- {%- endif -%}
-
+ {%- include "watch-overview-single-row.html" -%}
{%- endfor -%}
diff --git a/changedetectionio/tests/plugins/test_processor.py b/changedetectionio/tests/plugins/test_processor.py
index d0dfa4fb..2f2c64a8 100644
--- a/changedetectionio/tests/plugins/test_processor.py
+++ b/changedetectionio/tests/plugins/test_processor.py
@@ -8,7 +8,11 @@ from changedetectionio.tests.util import wait_for_all_checks
def test_check_plugin_processor(client, live_server, measure_memory_usage, datastore_path):
# requires os-int intelligence plugin installed (first basic one we test with)
- res = client.get(url_for("add_watch_ui.add_watch_ui_index"))
+ # Check the plugin processor shows up in the general "add watch" form on the watch-list overview
+ # (the unfiltered processor list). NOTE: not /add-watch-ui - that page is the visual/browser flow
+ # and only offers processors with supports_visual_selector; OSINT is a non-visual network-recon
+ # processor, so it correctly does NOT appear there.
+ res = client.get(url_for("watchlist.index"))
assert b'OSINT Reconnaissance' in res.data, "Must have the OSINT plugin installed at test time"
assert b'' in res.data, "But the first text_json_diff processor should always be selected by default in quick watch form"