From 4f489581872c9eeb6909c3338cac4fde2f3296a2 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Mon, 17 Mar 2025 11:48:35 +0100 Subject: [PATCH] WIP --- changedetectionio/conditions/__init__.py | 73 +++++++++++++++++++ changedetectionio/flask_app.py | 3 + changedetectionio/static/js/conditions.js | 70 +++++++++++++++++- changedetectionio/templates/_helpers.html | 1 + changedetectionio/templates/edit.html | 15 ++++ changedetectionio/tests/test_conditions.py | 85 +--------------------- 6 files changed, 163 insertions(+), 84 deletions(-) diff --git a/changedetectionio/conditions/__init__.py b/changedetectionio/conditions/__init__.py index 6c66c218..1ced1c93 100644 --- a/changedetectionio/conditions/__init__.py +++ b/changedetectionio/conditions/__init__.py @@ -1,3 +1,5 @@ +from flask import Blueprint, request, jsonify, Response +from flask_login import current_user from json_logic.builtins import BUILTINS from .exceptions import EmptyConditionRuleRowNotUsable @@ -5,6 +7,7 @@ from .pluggy_interface import plugin_manager # Import the pluggy plugin manager from . import default_plugin import re +import json # List of all supported JSON Logic operators operator_choices = [ @@ -131,6 +134,76 @@ def execute_ruleset_against_all_plugins(current_watch_uuid: str, application_dat return result +# Flask Blueprint Definition +def construct_blueprint(datastore): + from changedetectionio.flask_app import login_optionally_required + + conditions_blueprint = Blueprint('conditions', __name__, template_folder="templates") + + @conditions_blueprint.route("//verify-condition-single-rule", methods=['POST']) + @login_optionally_required + def verify_condition_single_rule(watch_uuid): + """Verify a single condition rule against the current snapshot""" + + # Get the watch data + watch = datastore.data['watching'].get(watch_uuid) + if not watch: + return jsonify({'status': 'error', 'message': 'Watch not found'}), 404 + + # Get the rule data from the request + rule_data = request.json + if not rule_data: + return jsonify({'status': 'error', 'message': 'No rule data provided'}), 400 + + # Create ephemeral data with the current snapshot + ephemeral_data = {} + + # Get the current snapshot if available + if watch.history_n and watch.get_last_fetched_text_before_filters(): + ephemeral_data['text'] = watch.get_last_fetched_text_before_filters() + else: + return jsonify({ + 'status': 'error', + 'message': 'No snapshot available for verification. Please fetch content first.' + }), 400 + + # Test the rule + result = False + try: + # Create a temporary structure with just this rule + temp_watch_data = { + "conditions": [rule_data], + "conditions_match_logic": "ALL" # Single rule, so use ALL + } + + # Create a temporary application data structure + temp_app_data = { + 'watching': { + watch_uuid: temp_watch_data + } + } + + # Execute the rule against the current snapshot + result = execute_ruleset_against_all_plugins( + current_watch_uuid=watch_uuid, + application_datastruct=temp_app_data, + ephemeral_data=ephemeral_data + ) + + return jsonify({ + 'status': 'success', + 'result': result, + 'message': 'Condition passes' if result else 'Condition does not pass' + }) + + except Exception as e: + return jsonify({ + 'status': 'error', + 'message': f'Error verifying condition: {str(e)}' + }), 500 + + return conditions_blueprint + # Load plugins dynamically for plugin in plugin_manager.get_plugins(): new_ops = plugin.register_operators() diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index a88df476..7101e0c0 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -1684,6 +1684,9 @@ def changedetection_app(config=None, datastore_o=None): import changedetectionio.blueprint.backups as backups app.register_blueprint(backups.construct_blueprint(datastore), url_prefix='/backups') + import changedetectionio.conditions as conditions + app.register_blueprint(conditions.construct_blueprint(datastore), url_prefix='/conditions') + # @todo handle ctrl break ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start() threading.Thread(target=notification_runner).start() diff --git a/changedetectionio/static/js/conditions.js b/changedetectionio/static/js/conditions.js index 1b983b51..2cb10b84 100644 --- a/changedetectionio/static/js/conditions.js +++ b/changedetectionio/static/js/conditions.js @@ -2,7 +2,7 @@ $(document).ready(function () { // Function to set up button event handlers function setupButtonHandlers() { // Unbind existing handlers first to prevent duplicates - $(".addRuleRow, .removeRuleRow").off("click"); + $(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click"); // Add row button handler $(".addRuleRow").on("click", function(e) { @@ -34,12 +34,78 @@ $(document).ready(function () { reindexRules(); } }); + + // Verify rule button handler + $(".verifyRuleRow").on("click", function(e) { + e.preventDefault(); + + let row = $(this).closest("tr"); + let field = row.find("select[name$='field']").val(); + let operator = row.find("select[name$='operator']").val(); + let value = row.find("input[name$='value']").val(); + + // Validate that all fields are filled + if (!field || field === "None" || !operator || operator === "None" || !value) { + alert("Please fill in all fields (Field, Operator, and Value) before verifying."); + return; + } + + // Extract the watch UUID from the URL + const url = window.location.pathname; + const uuidMatch = url.match(/\/edit\/([^\/]+)/); + if (!uuidMatch || !uuidMatch[1]) { + alert("Could not determine the watch UUID. Please save your changes first."); + return; + } + + const watchUuid = uuidMatch[1]; + + // Create a rule object + const rule = { + field: field, + operator: operator, + value: value + }; + + // Show a spinner or some indication that verification is in progress + const $button = $(this); + const originalHTML = $button.html(); + $button.html("⌛").prop("disabled", true); + + // Send the request to verify the rule + $.ajax({ + url: `/conditions/${watchUuid}/verify-condition-single-rule`, + type: "POST", + contentType: "application/json", + data: JSON.stringify(rule), + success: function(response) { + if (response.status === "success") { + if (response.result) { + alert("✅ Condition PASSES verification against current snapshot!"); + } else { + alert("❌ Condition FAILS verification against current snapshot."); + } + } else { + alert("Error: " + response.message); + } + $button.html(originalHTML).prop("disabled", false); + }, + error: function(xhr) { + let errorMsg = "Error verifying condition."; + if (xhr.responseJSON && xhr.responseJSON.message) { + errorMsg = xhr.responseJSON.message; + } + alert(errorMsg); + $button.html(originalHTML).prop("disabled", false); + } + }); + }); } // Function to reindex form elements and re-setup event handlers function reindexRules() { // Unbind all button handlers first - $(".addRuleRow, .removeRuleRow").off("click"); + $(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click"); // Reindex all form elements $("#rulesTable tbody tr").each(function(index) { diff --git a/changedetectionio/templates/_helpers.html b/changedetectionio/templates/_helpers.html index d0eec6df..461eb22e 100644 --- a/changedetectionio/templates/_helpers.html +++ b/changedetectionio/templates/_helpers.html @@ -89,6 +89,7 @@ + {% endfor %} diff --git a/changedetectionio/templates/edit.html b/changedetectionio/templates/edit.html index 3f7027fd..65c71231 100644 --- a/changedetectionio/templates/edit.html +++ b/changedetectionio/templates/edit.html @@ -286,9 +286,24 @@ Math: {{ 1 + 1 }}") }} {% if watch['processor'] == 'text_json_diff' %}
+
{{ render_field(form.conditions_match_logic) }} {{ render_fieldlist_of_formfields_as_table(form.conditions) }} +
+ Use the verify (✓) button to test if a condition passes against the current snapshot. +
diff --git a/changedetectionio/tests/test_conditions.py b/changedetectionio/tests/test_conditions.py index 3933c6ce..b30f667b 100644 --- a/changedetectionio/tests/test_conditions.py +++ b/changedetectionio/tests/test_conditions.py @@ -43,91 +43,12 @@ def set_number_out_of_range_response(number="150"): with open("test-datastore/endpoint-content.txt", "w") as f: f.write(test_return_data) -def test_setup(live_server): - live_server_setup(live_server) - -def test_number_within_range_condition(client, live_server, measure_memory_usage): - set_original_response() -# live_server_setup(live_server) - - test_url = url_for('test_endpoint', _external=True) - - # Add our URL to the import page - res = client.post( - url_for("import_page"), - data={"urls": test_url}, - follow_redirects=True - ) - assert b"1 Imported" in res.data - - # Configure the watch with two conditions: - # 1. The extracted number should be >= 20 and <= 100 - # 2. The first digit of the number should be in the page filtered text - res = client.post( - url_for("edit_page", uuid="first"), - data={ - "url": test_url, - "fetch_backend": "html_requests", - "include_filters": ".number-container", - "title": "Condition Test", - "conditions_match_logic": "ALL", # ALL = AND logic - - "conditions-0-operator": "in", - "conditions-0-field": "page_filtered_text", - "conditions-0-value": "5", - - "conditions-1-operator": ">=", - "conditions-1-field": "extracted_number", - "conditions-1-value": "20", - - "conditions-2-operator": "<=", - "conditions-2-field": "extracted_number", - "conditions-2-value": "100", - - }, - follow_redirects=True - ) - assert b"Updated watch." in res.data - - # Trigger initial check - client.get(url_for("form_watch_checknow"), follow_redirects=True) - wait_for_all_checks(client) - - # Set current view state - no unviewed - client.get(url_for("diff_history_page", uuid="first")) - - # Change that stays within the conditions (number is in range and text contains first digit) - set_number_in_range_response("75") - client.get(url_for("form_watch_checknow"), follow_redirects=True) - wait_for_all_checks(client) - - # Should be marked as having changes since all conditions are met - res = client.get(url_for("index")) - assert b'unviewed' in res.data - assert b'Condition Test' in res.data - - # Now change to value that's outside the range (but still contains the first digit in text) - set_number_out_of_range_response("150") - client.get(url_for("form_watch_checknow"), follow_redirects=True) - wait_for_all_checks(client) - - # Should NOT be marked as having changes since one condition is not met - res = client.get(url_for("index")) - assert b'unviewed' not in res.data - assert b'Condition Test' in res.data - - # Check the diff history shows the change from the earlier check (75) - res = client.get(url_for("diff_history_page", uuid="first")) - assert b'Current value: 75' in res.data - - res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) - assert b'Deleted' in res.data def test_conditions_with_text_and_number(client, live_server, measure_memory_usage): """Test that both text and number conditions work together with AND logic.""" set_original_response("50") - #live_server_setup(live_server) + live_server_setup(live_server) test_url = url_for('test_endpoint', _external=True) @@ -175,7 +96,7 @@ def test_conditions_with_text_and_number(client, live_server, measure_memory_usa # Case 1: The number is in range but the first digit changed (now 7 instead of 5) # This should NOT trigger a change notification since not all conditions are met - set_number_in_range_response("75") + set_number_in_range_response("70.5") client.get(url_for("form_watch_checknow"), follow_redirects=True) wait_for_all_checks(client) @@ -187,7 +108,7 @@ def test_conditions_with_text_and_number(client, live_server, measure_memory_usa # Case 2: Change with one condition violated # Number out of range (150) but contains '5' client.get(url_for("mark_all_viewed"), follow_redirects=True) - set_number_out_of_range_response("150") + set_number_out_of_range_response("150.5") client.get(url_for("form_watch_checknow"), follow_redirects=True) wait_for_all_checks(client)