Skip to content

Commit 7421e0f

Browse files
authored
New functionality - Time (weekday + time) scheduler / duration (#2802)
1 parent c6162e4 commit 7421e0f

18 files changed

+1094
-45
lines changed

changedetectionio/blueprint/tags/__init__.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
1313
def tags_overview_page():
1414
from .form import SingleTag
1515
add_form = SingleTag(request.form)
16+
1617
sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
1718

1819
from collections import Counter
@@ -104,9 +105,11 @@ def form_tag_edit(uuid):
104105

105106
default = datastore.data['settings']['application']['tags'].get(uuid)
106107

107-
form = group_restock_settings_form(formdata=request.form if request.method == 'POST' else None,
108+
form = group_restock_settings_form(
109+
formdata=request.form if request.method == 'POST' else None,
108110
data=default,
109-
extra_notification_tokens=datastore.get_unique_notification_tokens_available()
111+
extra_notification_tokens=datastore.get_unique_notification_tokens_available(),
112+
default_system_settings = datastore.data['settings'],
110113
)
111114

112115
template_args = {

changedetectionio/flask_app.py

+62-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22

33
import datetime
4+
from zoneinfo import ZoneInfo
45

56
import flask_login
67
import locale
@@ -38,12 +39,11 @@
3839
from flask_cors import CORS
3940
from flask_wtf import CSRFProtect
4041
from loguru import logger
41-
from zoneinfo import ZoneInfo
42-
4342

4443
from changedetectionio import html_tools, __version__
4544
from changedetectionio import queuedWatchMetaData
4645
from changedetectionio.api import api_v1
46+
from .time_handler import is_within_schedule
4747

4848
datastore = None
4949

@@ -718,7 +718,8 @@ def edit_page(uuid):
718718

719719
form = form_class(formdata=request.form if request.method == 'POST' else None,
720720
data=default,
721-
extra_notification_tokens=default.extra_notification_token_values()
721+
extra_notification_tokens=default.extra_notification_token_values(),
722+
default_system_settings=datastore.data['settings']
722723
)
723724

724725
# For the form widget tag UUID back to "string name" for the field
@@ -806,7 +807,33 @@ def edit_page(uuid):
806807
# But in the case something is added we should save straight away
807808
datastore.needs_write_urgent = True
808809

809-
if not datastore.data['watching'][uuid].get('paused'):
810+
# Do not queue on edit if its not within the time range
811+
812+
# @todo maybe it should never queue anyway on edit...
813+
is_in_schedule = True
814+
watch = datastore.data['watching'].get(uuid)
815+
816+
if watch.get('time_between_check_use_default'):
817+
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})
818+
else:
819+
time_schedule_limit = watch.get('time_schedule_limit')
820+
821+
tz_name = time_schedule_limit.get('timezone')
822+
if not tz_name:
823+
tz_name = datastore.data['settings']['application'].get('timezone', 'UTC')
824+
825+
if time_schedule_limit and time_schedule_limit.get('enabled'):
826+
try:
827+
is_in_schedule = is_within_schedule(time_schedule_limit=time_schedule_limit,
828+
default_tz=tz_name
829+
)
830+
except Exception as e:
831+
logger.error(
832+
f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
833+
return False
834+
835+
#############################
836+
if not datastore.data['watching'][uuid].get('paused') and is_in_schedule:
810837
# Queue the watch for immediate recheck, with a higher priority
811838
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
812839

@@ -838,15 +865,18 @@ def edit_page(uuid):
838865
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_'):
839866
is_html_webdriver = True
840867

868+
from zoneinfo import available_timezones
869+
841870
# Only works reliably with Playwright
842871
visualselector_enabled = os.getenv('PLAYWRIGHT_DRIVER_URL', False) and is_html_webdriver
843872
template_args = {
844873
'available_processors': processors.available_processors(),
874+
'available_timezones': sorted(available_timezones()),
845875
'browser_steps_config': browser_step_ui_config,
846876
'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
847-
'extra_title': f" - Edit - {watch.label}",
848-
'extra_processor_config': form.extra_tab_content(),
849877
'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
878+
'extra_processor_config': form.extra_tab_content(),
879+
'extra_title': f" - Edit - {watch.label}",
850880
'form': form,
851881
'has_default_notification_urls': True if len(datastore.data['settings']['application']['notification_urls']) else False,
852882
'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
@@ -855,6 +885,7 @@ def edit_page(uuid):
855885
'jq_support': jq_support,
856886
'playwright_enabled': os.getenv('PLAYWRIGHT_DRIVER_URL', False),
857887
'settings_application': datastore.data['settings']['application'],
888+
'timezone_default_config': datastore.data['settings']['application'].get('timezone'),
858889
'using_global_webdriver_wait': not default['webdriver_delay'],
859890
'uuid': uuid,
860891
'visualselector_enabled': visualselector_enabled,
@@ -885,6 +916,7 @@ def edit_page(uuid):
885916
def settings_page():
886917
from changedetectionio import forms
887918
from datetime import datetime
919+
from zoneinfo import available_timezones
888920

889921
default = deepcopy(datastore.data['settings'])
890922
if datastore.proxy_list is not None:
@@ -957,12 +989,14 @@ def settings_page():
957989

958990
output = render_template("settings.html",
959991
api_key=datastore.data['settings']['application'].get('api_access_token'),
992+
available_timezones=sorted(available_timezones()),
960993
emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
961994
extra_notification_token_placeholder_info=datastore.get_unique_notification_token_placeholders_available(),
962995
form=form,
963996
hide_remove_pass=os.getenv("SALTED_PASS", False),
964997
min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)),
965998
settings_application=datastore.data['settings']['application'],
999+
timezone_default_config=datastore.data['settings']['application'].get('timezone'),
9661000
utc_time=utc_time,
9671001
)
9681002

@@ -1637,7 +1671,6 @@ def highlight_submit_ignore_url():
16371671
import changedetectionio.blueprint.backups as backups
16381672
app.register_blueprint(backups.construct_blueprint(datastore), url_prefix='/backups')
16391673

1640-
16411674
# @todo handle ctrl break
16421675
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
16431676
threading.Thread(target=notification_runner).start()
@@ -1784,6 +1817,28 @@ def ticker_thread_check_time_launch_checks():
17841817
if watch['paused']:
17851818
continue
17861819

1820+
# @todo - Maybe make this a hook?
1821+
# Time schedule limit - Decide between watch or global settings
1822+
if watch.get('time_between_check_use_default'):
1823+
time_schedule_limit = datastore.data['settings']['requests'].get('time_schedule_limit', {})
1824+
logger.trace(f"{uuid} Time scheduler - Using system/global settings")
1825+
else:
1826+
time_schedule_limit = watch.get('time_schedule_limit')
1827+
logger.trace(f"{uuid} Time scheduler - Using watch settings (not global settings)")
1828+
tz_name = datastore.data['settings']['application'].get('timezone', 'UTC')
1829+
1830+
if time_schedule_limit and time_schedule_limit.get('enabled'):
1831+
try:
1832+
result = is_within_schedule(time_schedule_limit=time_schedule_limit,
1833+
default_tz=tz_name
1834+
)
1835+
if not result:
1836+
logger.trace(f"{uuid} Time scheduler - not within schedule skipping.")
1837+
continue
1838+
except Exception as e:
1839+
logger.error(
1840+
f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}")
1841+
return False
17871842
# If they supplied an individual entry minutes to threshold.
17881843
threshold = recheck_time_system_seconds if watch.get('time_between_check_use_default') else watch.threshold_seconds()
17891844

changedetectionio/forms.py

+106-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import os
22
import re
33
from loguru import logger
4+
from wtforms.widgets.core import TimeInput
45

56
from changedetectionio.strtobool import strtobool
67

78
from wtforms import (
89
BooleanField,
910
Form,
11+
Field,
1012
IntegerField,
1113
RadioField,
1214
SelectField,
@@ -125,6 +127,87 @@ def _value(self):
125127

126128
return 'error'
127129

130+
class TimeDurationForm(Form):
131+
hours = SelectField(choices=[(f"{i}", f"{i}") for i in range(0, 25)], default="24", validators=[validators.Optional()])
132+
minutes = SelectField(choices=[(f"{i}", f"{i}") for i in range(0, 60)], default="00", validators=[validators.Optional()])
133+
134+
class TimeStringField(Field):
135+
"""
136+
A WTForms field for time inputs (HH:MM) that stores the value as a string.
137+
"""
138+
widget = TimeInput() # Use the built-in time input widget
139+
140+
def _value(self):
141+
"""
142+
Returns the value for rendering in the form.
143+
"""
144+
return self.data if self.data is not None else ""
145+
146+
def process_formdata(self, valuelist):
147+
"""
148+
Processes the raw input from the form and stores it as a string.
149+
"""
150+
if valuelist:
151+
time_str = valuelist[0]
152+
# Simple validation for HH:MM format
153+
if not time_str or len(time_str.split(":")) != 2:
154+
raise ValidationError("Invalid time format. Use HH:MM.")
155+
self.data = time_str
156+
157+
158+
class validateTimeZoneName(object):
159+
"""
160+
Flask wtform validators wont work with basic auth
161+
"""
162+
163+
def __init__(self, message=None):
164+
self.message = message
165+
166+
def __call__(self, form, field):
167+
from zoneinfo import available_timezones
168+
python_timezones = available_timezones()
169+
if field.data and field.data not in python_timezones:
170+
raise ValidationError("Not a valid timezone name")
171+
172+
class ScheduleLimitDaySubForm(Form):
173+
enabled = BooleanField("not set", default=True)
174+
start_time = TimeStringField("Start At", default="00:00", render_kw={"placeholder": "HH:MM"}, validators=[validators.Optional()])
175+
duration = FormField(TimeDurationForm, label="Run duration")
176+
177+
class ScheduleLimitForm(Form):
178+
enabled = BooleanField("Use time scheduler", default=False)
179+
# Because the label for=""" doesnt line up/work with the actual checkbox
180+
monday = FormField(ScheduleLimitDaySubForm, label="")
181+
tuesday = FormField(ScheduleLimitDaySubForm, label="")
182+
wednesday = FormField(ScheduleLimitDaySubForm, label="")
183+
thursday = FormField(ScheduleLimitDaySubForm, label="")
184+
friday = FormField(ScheduleLimitDaySubForm, label="")
185+
saturday = FormField(ScheduleLimitDaySubForm, label="")
186+
sunday = FormField(ScheduleLimitDaySubForm, label="")
187+
188+
timezone = StringField("Optional timezone to run in",
189+
render_kw={"list": "timezones"},
190+
validators=[validateTimeZoneName()]
191+
)
192+
def __init__(
193+
self,
194+
formdata=None,
195+
obj=None,
196+
prefix="",
197+
data=None,
198+
meta=None,
199+
**kwargs,
200+
):
201+
super().__init__(formdata, obj, prefix, data, meta, **kwargs)
202+
self.monday.form.enabled.label.text="Monday"
203+
self.tuesday.form.enabled.label.text = "Tuesday"
204+
self.wednesday.form.enabled.label.text = "Wednesday"
205+
self.thursday.form.enabled.label.text = "Thursday"
206+
self.friday.form.enabled.label.text = "Friday"
207+
self.saturday.form.enabled.label.text = "Saturday"
208+
self.sunday.form.enabled.label.text = "Sunday"
209+
210+
128211
class TimeBetweenCheckForm(Form):
129212
weeks = IntegerField('Weeks', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
130213
days = IntegerField('Days', validators=[validators.Optional(), validators.NumberRange(min=0, message="Should contain zero or more seconds")])
@@ -279,6 +362,7 @@ def __call__(self, form, field):
279362
# This should raise a ValidationError() or not
280363
validate_url(field.data)
281364

365+
282366
def validate_url(test_url):
283367
# If hosts that only contain alphanumerics are allowed ("localhost" for example)
284368
try:
@@ -438,6 +522,7 @@ def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **k
438522
notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
439523
notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])
440524
processor = RadioField( label=u"Processor - What do you want to achieve?", choices=processors.available_processors(), default="text_json_diff")
525+
timezone = StringField("Timezone for watch schedule", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
441526
webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")])
442527

443528

@@ -448,7 +533,6 @@ class importForm(Form):
448533
xlsx_file = FileField('Upload .xlsx file', validators=[FileAllowed(['xlsx'], 'Must be .xlsx file!')])
449534
file_mapping = SelectField('File mapping', [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')})
450535

451-
452536
class SingleBrowserStep(Form):
453537

454538
operation = SelectField('Operation', [validators.Optional()], choices=browser_step_ui_config.keys())
@@ -466,6 +550,9 @@ class processor_text_json_diff_form(commonSettingsForm):
466550
tags = StringTagUUID('Group tag', [validators.Optional()], default='')
467551

468552
time_between_check = FormField(TimeBetweenCheckForm)
553+
554+
time_schedule_limit = FormField(ScheduleLimitForm)
555+
469556
time_between_check_use_default = BooleanField('Use global settings for time between check', default=False)
470557

471558
include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='')
@@ -567,6 +654,23 @@ def validate(self, **kwargs):
567654

568655
return result
569656

657+
def __init__(
658+
self,
659+
formdata=None,
660+
obj=None,
661+
prefix="",
662+
data=None,
663+
meta=None,
664+
**kwargs,
665+
):
666+
super().__init__(formdata, obj, prefix, data, meta, **kwargs)
667+
if kwargs and kwargs.get('default_system_settings'):
668+
default_tz = kwargs.get('default_system_settings').get('application', {}).get('timezone')
669+
if default_tz:
670+
self.time_schedule_limit.form.timezone.render_kw['placeholder'] = default_tz
671+
672+
673+
570674
class SingleExtraProxy(Form):
571675

572676
# maybe better to set some <script>var..
@@ -587,6 +691,7 @@ class DefaultUAInputForm(Form):
587691
# datastore.data['settings']['requests']..
588692
class globalSettingsRequestForm(Form):
589693
time_between_check = FormField(TimeBetweenCheckForm)
694+
time_schedule_limit = FormField(ScheduleLimitForm)
590695
proxy = RadioField('Proxy')
591696
jitter_seconds = IntegerField('Random jitter seconds ± check',
592697
render_kw={"style": "width: 5em;"},

changedetectionio/model/App.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class model(dict):
5353
'shared_diff_access': False,
5454
'webdriver_delay': None , # Extra delay in seconds before extracting text
5555
'tags': {}, #@todo use Tag.model initialisers
56-
'timezone': None,
56+
'timezone': None, # Default IANA timezone name
5757
}
5858
}
5959
}

changedetectionio/model/Watch.py

-1
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,6 @@ def save_history_text(self, contents, timestamp, snapshot_id):
339339
# @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status
340340
return snapshot_fname
341341

342-
@property
343342
@property
344343
def has_empty_checktime(self):
345344
# using all() + dictionary comprehension

0 commit comments

Comments
 (0)