This commit is contained in:
dgtlmoon
2025-03-17 11:48:35 +01:00
parent 2608980b1d
commit 4f48958187
6 changed files with 163 additions and 84 deletions
+73
View File
@@ -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("/<string:watch_uuid>/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()
+3
View File
@@ -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()
+68 -2
View File
@@ -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) {
@@ -89,6 +89,7 @@
<td>
<button type="button" class="addRuleRow">+</button>
<button type="button" class="removeRuleRow">-</button>
<button type="button" class="verifyRuleRow" title="Verify this rule against current snapshot"></button>
</td>
</tr>
{% endfor %}
+15
View File
@@ -286,9 +286,24 @@ Math: {{ 1 + 1 }}") }}
{% if watch['processor'] == 'text_json_diff' %}
<div class="tab-pane-inner" id="conditions">
<style>
.verifyRuleRow {
background-color: #4caf50;
color: white;
border: none;
cursor: pointer;
font-weight: bold;
}
.verifyRuleRow:hover {
background-color: #45a049;
}
</style>
<div class="pure-control-group">
{{ render_field(form.conditions_match_logic) }}
{{ render_fieldlist_of_formfields_as_table(form.conditions) }}
<div class="pure-form-message-inline">
Use the verify (✓) button to test if a condition passes against the current snapshot.
</div>
</div>
</div>
<div class="tab-pane-inner" id="filters-and-triggers">
+3 -82
View File
@@ -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)