diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 8a8cb437f85..e496fccdf2e 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -43,6 +43,7 @@ from changedetectionio import html_tools, __version__ from changedetectionio import queuedWatchMetaData from changedetectionio.api import api_v1 +from .time_handler import is_within_schedule datastore = None @@ -807,16 +808,31 @@ def edit_page(uuid): datastore.needs_write_urgent = True # Do not queue on edit if its not within the time range - # @todo connect with watch.get('time_between_check_use_default') + # @todo maybe it should never queue anyway on edit... is_in_schedule = True - time_schedule_limit = datastore.data['watching'][uuid].get('time_schedule_limit') - if time_schedule_limit and time_schedule_limit.get('enabled'): - is_in_schedule = datastore.data['watching'][uuid].watch_recheck_is_within_schedule( - default_tz=datastore.data['settings']['application'].get('timezone', 'UTC') - ) + 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', {}) + else: + time_schedule_limit = watch.get('time_schedule_limit') + + tz_name = time_schedule_limit.get('timezone') + if not tz_name: + tz_name = datastore.data['settings']['application'].get('timezone', 'UTC') + if time_schedule_limit and time_schedule_limit.get('enabled'): + try: + is_in_schedule = is_within_schedule(time_schedule_limit=time_schedule_limit, + default_tz=tz_name + ) + except Exception as e: + logger.error( + f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}") + return False + + ############################# if not datastore.data['watching'][uuid].get('paused') and is_in_schedule: # Queue the watch for immediate recheck, with a higher priority update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) @@ -1801,18 +1817,28 @@ def ticker_thread_check_time_launch_checks(): if watch['paused']: continue + # @todo - Maybe make this a hook? + # Time schedule limit - Decide between watch or global settings + if watch.get('time_between_check_use_default'): + time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {}) + logger.trace(f"{uuid} Time scheduler - Using system/global settings") + else: + time_schedule_limit = watch.get('time_schedule_limit') + logger.trace(f"{uuid} Time scheduler - Using watch settings (not global settings)") + tz_name = datastore.data['settings']['application'].get('timezone', 'UTC') - # Maybe make this a hook? - - # Check if we are inside the time range - # @todo connect with watch.get('time_between_check_use_default') - time_schedule_limit = watch.get('time_schedule_limit') if time_schedule_limit and time_schedule_limit.get('enabled'): - result = watch.watch_recheck_is_within_schedule(default_tz=datastore.data['settings']['application'].get('timezone', 'UTC')) - if not result: - logger.trace(f"{uuid} Time scheduler - not within schedule skipping.") - continue - + try: + result = is_within_schedule(time_schedule_limit=time_schedule_limit, + default_tz=tz_name + ) + if not result: + logger.trace(f"{uuid} Time scheduler - not within schedule skipping.") + continue + except Exception as e: + logger.error( + f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}") + return False # If they supplied an individual entry minutes to threshold. threshold = recheck_time_system_seconds if watch.get('time_between_check_use_default') else watch.threshold_seconds() diff --git a/changedetectionio/model/Watch.py b/changedetectionio/model/Watch.py index f5e008bd90d..03bbee82e8f 100644 --- a/changedetectionio/model/Watch.py +++ b/changedetectionio/model/Watch.py @@ -1,3 +1,5 @@ +from more_itertools.more import time_limited + from changedetectionio.strtobool import strtobool from changedetectionio.safe_jinja import render as jinja_render from changedetectionio.time_handler import am_i_inside_time @@ -631,38 +633,6 @@ def _prune_last_fetched_html_snapshots(self): if index > 1 and os.path.isfile(filepath): os.remove(filepath) - def watch_recheck_is_within_schedule(self, default_tz="UTC"): - from datetime import datetime - - # Check if we are inside the time range - time_schedule_limit = self.get('time_schedule_limit') - 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') - if not tz_name: - tz_name = default_tz - - try: - now_day_name_in_tz = datetime.now(ZoneInfo(tz_name.strip())).strftime('%A') - except Exception as e: - logger.error( - f"{self.get('uuid')} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}") - return False - - selected_day_schedule = time_schedule_limit.get(now_day_name_in_tz.lower()) - if not selected_day_schedule.get('enabled'): - logger.trace(f"{self.get('uuid')} - Skipped check for {now_day_name_in_tz} in {tz_name}, not enabled.") - return False - - duration = selected_day_schedule.get('duration') - selected_day_run_duration_m = int(duration.get('hours')) * 60 + int(duration.get('minutes')) - - is_valid = am_i_inside_time(day_of_week=now_day_name_in_tz, - time_str=selected_day_schedule['start_time'], - timezone_str=tz_name, - duration=selected_day_run_duration_m) - - return is_valid @property def get_browsersteps_available_screenshots(self): diff --git a/changedetectionio/tests/test_scheduler.py b/changedetectionio/tests/test_scheduler.py index 4c34757963c..915982e8e61 100644 --- a/changedetectionio/tests/test_scheduler.py +++ b/changedetectionio/tests/test_scheduler.py @@ -20,7 +20,7 @@ def test_check_basic_scheduler_functionality(client, live_server, measure_memory url_for("settings_page"), data={"application-empty_pages_are_a_change": "", "requests-time_between_check-seconds": 1, - "application-timezone": "Pacific/Kiritimati", # Most Forward Time Zone (UTC+14:00) + "application-timezone": "Pacific/Kiritimati", # Most Forward Time Zone (UTC+14:00) 'application-fetch_backend': "html_requests"}, follow_redirects=True ) @@ -71,7 +71,6 @@ def test_check_basic_scheduler_functionality(client, live_server, measure_memory ) assert b"Updated watch." in res.data - res = client.get(url_for("edit_page", uuid="first")) assert b"Pacific/Kiritimati" in res.data, "Should be Pacific/Kiritimati in placeholder data" @@ -79,7 +78,6 @@ def test_check_basic_scheduler_functionality(client, live_server, measure_memory time.sleep(2) assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] == last_check - # Enabling today in Kiritimati should work flawless kiritimati_time = datetime.now(timezone.utc).astimezone(ZoneInfo("Pacific/Kiritimati")) kiritimati_time_day_of_week = kiritimati_time.strftime("%A").lower() @@ -87,6 +85,93 @@ def test_check_basic_scheduler_functionality(client, live_server, measure_memory time.sleep(3) assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] != last_check + # Cleanup everything + res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) + assert b'Deleted' in res.data + + +def test_check_basic_global_scheduler_functionality(client, live_server, measure_memory_usage): + live_server_setup(live_server) + days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + test_url = url_for('test_random_content_endpoint', _external=True) + + res = client.post( + url_for("import_page"), + data={"urls": test_url}, + follow_redirects=True + ) + + assert b"1 Imported" in res.data + wait_for_all_checks(client) + uuid = extract_UUID_from_client(client) + + # Setup all the days of the weeks using XXX as the placeholder for monday/tuesday/etc + + tpl = { + "requests-time_schedule_limit-XXX-start_time": "00:00", + "requests-time_schedule_limit-XXX-duration-hours": 24, + "requests-time_schedule_limit-XXX-duration-minutes": 0, + "requests-time_schedule_limit-XXX-enabled": '', # All days are turned off + "requests-time_schedule_limit-enabled": 'y', # Scheduler is enabled, all days however are off. + } + + scheduler_data = {} + for day in days: + for key, value in tpl.items(): + # Replace "XXX" with the current day in the key + new_key = key.replace("XXX", day) + scheduler_data[new_key] = value + + data = { + "application-empty_pages_are_a_change": "", + "application-timezone": "Pacific/Kiritimati", # Most Forward Time Zone (UTC+14:00) + 'application-fetch_backend': "html_requests", + "requests-time_between_check-hours": 0, + "requests-time_between_check-minutes": 0, + "requests-time_between_check-seconds": 1, + } + data.update(scheduler_data) + + ##################### + res = client.post( + url_for("settings_page"), + data=data, + follow_redirects=True + ) + + assert b"Settings updated." in res.data + + res = client.get(url_for("settings_page")) + assert b'Pacific/Kiritimati' in res.data + + wait_for_all_checks(client) + + # UI Sanity check + + res = client.get(url_for("edit_page", uuid="first")) + assert b"Pacific/Kiritimati" in res.data, "Should be Pacific/Kiritimati in placeholder data" + + #### HITTING SAVE SHOULD NOT TRIGGER A CHECK + last_check = live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] + res = client.post( + url_for("edit_page", uuid="first"), + data={ + "url": test_url, + "fetch_backend": "html_requests", + "time_between_check_use_default": "y"}, + follow_redirects=True + ) + assert b"Updated watch." in res.data + time.sleep(2) + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] == last_check + + # Enabling "today" in Kiritimati time should make the system check that watch + kiritimati_time = datetime.now(timezone.utc).astimezone(ZoneInfo("Pacific/Kiritimati")) + kiritimati_time_day_of_week = kiritimati_time.strftime("%A").lower() + live_server.app.config['DATASTORE'].data['settings']['requests']['time_schedule_limit'][kiritimati_time_day_of_week]["enabled"] = True + + time.sleep(3) + assert live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked'] != last_check # Cleanup everything res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True) diff --git a/changedetectionio/time_handler.py b/changedetectionio/time_handler.py index 1d30b9f9681..ba071f40a84 100644 --- a/changedetectionio/time_handler.py +++ b/changedetectionio/time_handler.py @@ -2,6 +2,7 @@ from enum import IntEnum from zoneinfo import ZoneInfo + class Weekday(IntEnum): """Enumeration for days of the week.""" Monday = 0 @@ -14,10 +15,10 @@ class Weekday(IntEnum): def am_i_inside_time( - day_of_week: str, - time_str: str, - timezone_str: str, - duration: int = 15, + day_of_week: str, + time_str: str, + timezone_str: str, + duration: int = 15, ) -> bool: """ Determines if the current time falls within a specified time range. @@ -78,3 +79,27 @@ def am_i_inside_time( return False + +def is_within_schedule(time_schedule_limit, default_tz="UTC"): + 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') + if not tz_name: + tz_name = default_tz + + now_day_name_in_tz = datetime.now(ZoneInfo(tz_name.strip())).strftime('%A') + selected_day_schedule = time_schedule_limit.get(now_day_name_in_tz.lower()) + if not selected_day_schedule.get('enabled'): + return False + + duration = selected_day_schedule.get('duration') + selected_day_run_duration_m = int(duration.get('hours')) * 60 + int(duration.get('minutes')) + + is_valid = am_i_inside_time(day_of_week=now_day_name_in_tz, + time_str=selected_day_schedule['start_time'], + timezone_str=tz_name, + duration=selected_day_run_duration_m) + + return is_valid + + return False