diff --git a/changedetectionio/blueprint/tags/__init__.py b/changedetectionio/blueprint/tags/__init__.py index ca974666..d7086213 100644 --- a/changedetectionio/blueprint/tags/__init__.py +++ b/changedetectionio/blueprint/tags/__init__.py @@ -13,6 +13,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): def tags_overview_page(): from .form import SingleTag add_form = SingleTag(request.form) + sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title']) from collections import Counter @@ -104,9 +105,11 @@ def construct_blueprint(datastore: ChangeDetectionStore): default = datastore.data['settings']['application']['tags'].get(uuid) - form = group_restock_settings_form(formdata=request.form if request.method == 'POST' else None, + form = group_restock_settings_form( + formdata=request.form if request.method == 'POST' else None, data=default, - extra_notification_tokens=datastore.get_unique_notification_tokens_available() + extra_notification_tokens=datastore.get_unique_notification_tokens_available(), + default_system_settings = datastore.data['settings'], ) template_args = { diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 37da7699..e496fccd 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import datetime +from zoneinfo import ZoneInfo import flask_login import locale @@ -38,12 +39,11 @@ from flask_restful import abort, Api from flask_cors import CORS from flask_wtf import CSRFProtect from loguru import logger -from zoneinfo import ZoneInfo - 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 @@ -718,7 +718,8 @@ def changedetection_app(config=None, datastore_o=None): form = form_class(formdata=request.form if request.method == 'POST' else None, data=default, - extra_notification_tokens=default.extra_notification_token_values() + extra_notification_tokens=default.extra_notification_token_values(), + default_system_settings=datastore.data['settings'] ) # For the form widget tag UUID back to "string name" for the field @@ -806,7 +807,33 @@ def changedetection_app(config=None, datastore_o=None): # But in the case something is added we should save straight away datastore.needs_write_urgent = True - if not datastore.data['watching'][uuid].get('paused'): + # Do not queue on edit if its not within the time range + + # @todo maybe it should never queue anyway on edit... + is_in_schedule = True + 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})) @@ -838,15 +865,18 @@ def changedetection_app(config=None, datastore_o=None): if (watch.get('fetch_backend') == 'system' and system_uses_webdriver) or watch.get('fetch_backend') == 'html_webdriver' or watch.get('fetch_backend', '').startswith('extra_browser_'): is_html_webdriver = True + from zoneinfo import available_timezones + # Only works reliably with Playwright visualselector_enabled = os.getenv('PLAYWRIGHT_DRIVER_URL', False) and is_html_webdriver template_args = { 'available_processors': processors.available_processors(), + 'available_timezones': sorted(available_timezones()), 'browser_steps_config': browser_step_ui_config, 'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), - 'extra_title': f" - Edit - {watch.label}", - 'extra_processor_config': form.extra_tab_content(), 'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(), + 'extra_processor_config': form.extra_tab_content(), + 'extra_title': f" - Edit - {watch.label}", 'form': form, 'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False, 'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0, @@ -855,6 +885,7 @@ def changedetection_app(config=None, datastore_o=None): 'jq_support': jq_support, 'playwright_enabled': os.getenv('PLAYWRIGHT_DRIVER_URL', False), 'settings_application': datastore.data['settings']['application'], + 'timezone_default_config': datastore.data['settings']['application'].get('timezone'), 'using_global_webdriver_wait': not default['webdriver_delay'], 'uuid': uuid, 'visualselector_enabled': visualselector_enabled, @@ -885,6 +916,7 @@ def changedetection_app(config=None, datastore_o=None): def settings_page(): from changedetectionio import forms from datetime import datetime + from zoneinfo import available_timezones default = deepcopy(datastore.data['settings']) if datastore.proxy_list is not None: @@ -957,12 +989,14 @@ def changedetection_app(config=None, datastore_o=None): output = render_template("settings.html", api_key=datastore.data['settings']['application'].get('api_access_token'), + available_timezones=sorted(available_timezones()), emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(), form=form, hide_remove_pass=os.getenv("SALTED_PASS", False), min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)), settings_application=datastore.data['settings']['application'], + timezone_default_config=datastore.data['settings']['application'].get('timezone'), utc_time=utc_time, ) @@ -1637,7 +1671,6 @@ def changedetection_app(config=None, datastore_o=None): import changedetectionio.blueprint.backups as backups app.register_blueprint(backups.construct_blueprint(datastore), url_prefix='/backups') - # @todo handle ctrl break ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start() threading.Thread(target=notification_runner).start() @@ -1784,6 +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') + + if time_schedule_limit and time_schedule_limit.get('enabled'): + 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/forms.py b/changedetectionio/forms.py index 81869988..121b4458 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -1,12 +1,14 @@ import os import re from loguru import logger +from wtforms.widgets.core import TimeInput from changedetectionio.strtobool import strtobool from wtforms import ( BooleanField, Form, + Field, IntegerField, RadioField, SelectField, @@ -125,6 +127,87 @@ class StringTagUUID(StringField): return 'error' +class TimeDurationForm(Form): + hours = SelectField(choices=[(f"{i}", f"{i}") for i in range(0, 25)], default="24", validators=[validators.Optional()]) + minutes = SelectField(choices=[(f"{i}", f"{i}") for i in range(0, 60)], default="00", validators=[validators.Optional()]) + +class TimeStringField(Field): + """ + A WTForms field for time inputs (HH:MM) that stores the value as a string. + """ + widget = TimeInput() # Use the built-in time input widget + + def _value(self): + """ + Returns the value for rendering in the form. + """ + return self.data if self.data is not None else "" + + def process_formdata(self, valuelist): + """ + Processes the raw input from the form and stores it as a string. + """ + if valuelist: + time_str = valuelist[0] + # Simple validation for HH:MM format + if not time_str or len(time_str.split(":")) != 2: + raise ValidationError("Invalid time format. Use HH:MM.") + self.data = time_str + + +class validateTimeZoneName(object): + """ + Flask wtform validators wont work with basic auth + """ + + def __init__(self, message=None): + self.message = message + + def __call__(self, form, field): + from zoneinfo import available_timezones + python_timezones = available_timezones() + if field.data and field.data not in python_timezones: + raise ValidationError("Not a valid timezone name") + +class ScheduleLimitDaySubForm(Form): + enabled = BooleanField("not set", default=True) + start_time = TimeStringField("Start At", default="00:00", render_kw={"placeholder": "HH:MM"}, validators=[validators.Optional()]) + duration = FormField(TimeDurationForm, label="Run duration") + +class ScheduleLimitForm(Form): + enabled = BooleanField("Use time scheduler", default=False) + # Because the label for=""" doesnt line up/work with the actual checkbox + monday = FormField(ScheduleLimitDaySubForm, label="") + tuesday = FormField(ScheduleLimitDaySubForm, label="") + wednesday = FormField(ScheduleLimitDaySubForm, label="") + thursday = FormField(ScheduleLimitDaySubForm, label="") + friday = FormField(ScheduleLimitDaySubForm, label="") + saturday = FormField(ScheduleLimitDaySubForm, label="") + sunday = FormField(ScheduleLimitDaySubForm, label="") + + timezone = StringField("Optional timezone to run in", + render_kw={"list": "timezones"}, + validators=[validateTimeZoneName()] + ) + def __init__( + self, + formdata=None, + obj=None, + prefix="", + data=None, + meta=None, + **kwargs, + ): + super().__init__(formdata, obj, prefix, data, meta, **kwargs) + self.monday.form.enabled.label.text="Monday" + self.tuesday.form.enabled.label.text = "Tuesday" + self.wednesday.form.enabled.label.text = "Wednesday" + self.thursday.form.enabled.label.text = "Thursday" + self.friday.form.enabled.label.text = "Friday" + self.saturday.form.enabled.label.text = "Saturday" + self.sunday.form.enabled.label.text = "Sunday" + + class TimeBetweenCheckForm(Form): weeks = IntegerField('Weeks', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) days = IntegerField('Days', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")]) @@ -279,6 +362,7 @@ class validateURL(object): # This should raise a ValidationError() or not validate_url(field.data) + def validate_url(test_url): # If hosts that only contain alphanumerics are allowed ("localhost" for example) try: @@ -438,6 +522,7 @@ class commonSettingsForm(Form): notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()]) notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()]) processor = RadioField( label=u"Processor - What do you want to achieve?", choices=processors.available_processors(), default="text_json_diff") + timezone = StringField("Timezone for watch schedule", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()]) webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")]) @@ -448,7 +533,6 @@ class importForm(Form): xlsx_file = FileField('Upload .xlsx file', validators=[FileAllowed(['xlsx'], 'Must be .xlsx file!')]) file_mapping = SelectField('File mapping', [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')}) - class SingleBrowserStep(Form): operation = SelectField('Operation', [validators.Optional()], choices=browser_step_ui_config.keys()) @@ -466,6 +550,9 @@ class processor_text_json_diff_form(commonSettingsForm): tags = StringTagUUID('Group tag', [validators.Optional()], default='') time_between_check = FormField(TimeBetweenCheckForm) + + time_schedule_limit = FormField(ScheduleLimitForm) + time_between_check_use_default = BooleanField('Use global settings for time between check', default=False) include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='') @@ -567,6 +654,23 @@ class processor_text_json_diff_form(commonSettingsForm): return result + def __init__( + self, + formdata=None, + obj=None, + prefix="", + data=None, + meta=None, + **kwargs, + ): + super().__init__(formdata, obj, prefix, data, meta, **kwargs) + if kwargs and kwargs.get('default_system_settings'): + default_tz = kwargs.get('default_system_settings').get('application', {}).get('timezone') + if default_tz: + self.time_schedule_limit.form.timezone.render_kw['placeholder'] = default_tz + + + class SingleExtraProxy(Form): # maybe better to set some + + +
UTC Time from Server: {{ utc_time }}
-Local Time in Browser:
-UTC Time & Date from Server: {{ utc_time }}
+Local Time & Date in Browser:
++ {{ render_field(form.application.form.timezone) }} + +
+