From 55ca6691af04346334a2cde2c1bc0c394ede2fa1 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sun, 16 Aug 2026 15:09:01 +0200 Subject: [PATCH] UI - Move rendering of each watchlist row out of the watchlist main template so it can be re-used by the realtime update --- .../blueprint/watchlist/__init__.py | 16 +- .../blueprint/watchlist/row_context.py | 43 +++++ .../templates/watch-overview-single-row.html | 172 ++++++++++++++++++ .../watchlist/templates/watch-overview.html | 166 +---------------- .../tests/plugins/test_processor.py | 6 +- 5 files changed, 229 insertions(+), 174 deletions(-) create mode 100644 changedetectionio/blueprint/watchlist/row_context.py create mode 100644 changedetectionio/blueprint/watchlist/templates/watch-overview-single-row.html 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'] %} + + {% endif %} +
+ {%- if watch['processor'] and watch['processor'] in processor_badge_texts -%} + {{ processor_badge_texts[watch['processor']] }} + {%- endif -%} + + {% if system_use_url_watchlist or watch.get('use_page_title_in_list') %} + {{ watch.label }} + {% else %} + {{ watch.get('title') or watch.link }} + {% endif %} +   + + + {%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%} + {{ watch_tag.title }} + {%- endfor -%} + + {%- if watch['processor'] == 'text_json_diff' -%} + {%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%} +
Switch to Restock & Price watch mode? Yes No
+ {%- endif -%} + {%- 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 -%}Converting PDF to text{%- endif -%} + {%- if watch.has_browser_steps -%}Browser Steps is enabled{%- 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 -%} +
+{%- endif -%} +
+ + +{# +{%- if any_has_restock_price_processor -%} + + +{%- endif -%} +#} + {#last_checked becomes fetch-start-time#} + + + {{watch|format_last_checked_time|safe}} + + + {%- if watch.history_n >=2 and watch.last_changed >0 -%} + {{watch.last_changed|format_timestamp_timeago}} + {%- else -%} + {{ _('Not yet') }} + {%- endif -%} + + + +
+ {%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%} + + {{ _('Recheck') }} + {{ _('Edit') }} + + +
+ + diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html index 3a4217e0..c847c677 100644 --- a/changedetectionio/blueprint/watchlist/templates/watch-overview.html +++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html @@ -215,12 +215,9 @@ window.watchOverviewI18n = { {%- set sort_attribute = sort_attribute or 'last_changed' -%} {%- set pagination_page = request.args.get('page', 0) -%} {%- set cols_required = 6 -%} - {%- set any_has_restock_price_processor = datastore.any_watches_have_processor_by_name("restock_diff") -%} - {%- set any_has_restock_price_processor = 0 -%} {%- if any_has_restock_price_processor -%} {%- set cols_required = cols_required + 1 -%} {%- endif -%} - {%- set ui_settings = datastore.data['settings']['application']['ui'] -%} {%- set wrapper_classes = [ 'has-unread-changes' if unread_changes_count else '', 'has-error' if errored_count else '', @@ -350,168 +347,7 @@ window.watchOverviewI18n = { {%- endif -%} {%- for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) -%} - {%- 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'] %} - - {% endif %} -
- {%- if watch['processor'] and watch['processor'] in processor_badge_texts -%} - {{ processor_badge_texts[watch['processor']] }} - {%- endif -%} - - {% if system_use_url_watchlist or watch.get('use_page_title_in_list') %} - {{ watch.label }} - {% else %} - {{ watch.get('title') or watch.link }} - {% endif %} -   - - - {%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%} - {{ watch_tag.title }} - {%- endfor -%} - - {%- if watch['processor'] == 'text_json_diff' -%} - {%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] -%} -
Switch to Restock & Price watch mode? Yes No
- {%- endif -%} - {%- 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 -%}Converting PDF to text{%- endif -%} - {%- if watch.has_browser_steps -%}Browser Steps is enabled{%- 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 -%} -
-{%- endif -%} -
- - -{# -{%- if any_has_restock_price_processor -%} - - -{%- endif -%} -#} - {#last_checked becomes fetch-start-time#} - - - {{watch|format_last_checked_time|safe}} - - - {%- if watch.history_n >=2 and watch.last_changed >0 -%} - {{watch.last_changed|format_timestamp_timeago}} - {%- else -%} - {{ _('Not yet') }} - {%- endif -%} - - - -
- {%- set target_attr = ' target="' ~ watch.uuid ~ '"' if datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') else '' -%} - - {{ _('Recheck') }} - {{ _('Edit') }} - - -
- - + {%- 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"