New functionality - Time (weekday + time) scheduler / duration (#2802)
parent
c6162e48f1
commit
7421e0f95e
After Width: | Height: | Size: 5.9 KiB |
@ -0,0 +1,103 @@
|
|||||||
|
function getTimeInTimezone(timezone) {
|
||||||
|
const now = new Date();
|
||||||
|
const options = {
|
||||||
|
timeZone: timezone,
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour12: false,
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', options);
|
||||||
|
return formatter.format(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
|
||||||
|
let exceedsLimit = false;
|
||||||
|
const warning_text = $("#timespan-warning")
|
||||||
|
const timezone_text_widget = $("input[id*='time_schedule_limit-timezone']")
|
||||||
|
|
||||||
|
toggleVisibility('#time_schedule_limit-enabled, #requests-time_schedule_limit-enabled', '#schedule-day-limits-wrapper', true)
|
||||||
|
|
||||||
|
$('#schedule-day-limits-wrapper').on('change click blur', 'input, checkbox, select', function() {
|
||||||
|
|
||||||
|
if (timezone_text_widget.val().length) {
|
||||||
|
document.getElementById('local-time-in-tz').textContent =
|
||||||
|
getTimeInTimezone(timezone_text_widget.val());
|
||||||
|
} else {
|
||||||
|
// So maybe use what is in the placeholder (which will be the default settings)
|
||||||
|
document.getElementById('local-time-in-tz').textContent =
|
||||||
|
getTimeInTimezone(timezone_text_widget.attr('placeholder'));
|
||||||
|
}
|
||||||
|
let allOk = true;
|
||||||
|
|
||||||
|
// Controls setting the warning that the time could overlap into the next day
|
||||||
|
$("li.day-schedule").each(function () {
|
||||||
|
const $schedule = $(this);
|
||||||
|
const $checkbox = $schedule.find("input[type='checkbox']");
|
||||||
|
|
||||||
|
if ($checkbox.is(":checked")) {
|
||||||
|
const timeValue = $schedule.find("input[type='time']").val();
|
||||||
|
const durationHours = parseInt($schedule.find("select[name*='-duration-hours']").val(), 10) || 0;
|
||||||
|
const durationMinutes = parseInt($schedule.find("select[name*='-duration-minutes']").val(), 10) || 0;
|
||||||
|
|
||||||
|
if (timeValue) {
|
||||||
|
const [startHours, startMinutes] = timeValue.split(":").map(Number);
|
||||||
|
const totalMinutes = (startHours * 60 + startMinutes) + (durationHours * 60 + durationMinutes);
|
||||||
|
|
||||||
|
exceedsLimit = totalMinutes > 1440
|
||||||
|
if (exceedsLimit) {
|
||||||
|
allOk = false
|
||||||
|
}
|
||||||
|
// Set the row/day-of-week highlight
|
||||||
|
$schedule.toggleClass("warning", exceedsLimit);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$schedule.toggleClass("warning", false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
warning_text.toggle(!allOk)
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
$('table[id*="time_schedule_limit-saturday"], table[id*="time_schedule_limit-sunday"]').addClass("weekend-day")
|
||||||
|
|
||||||
|
// Presets [weekend] [business hours] etc
|
||||||
|
$(document).on('click', '[data-template].set-schedule', function () {
|
||||||
|
// Get the value of the 'data-template' attribute
|
||||||
|
|
||||||
|
switch ($(this).attr('data-template')) {
|
||||||
|
case 'business-hours':
|
||||||
|
$('.day-schedule table:not(.weekend-day) input[type="time"]').val('09:00')
|
||||||
|
$('.day-schedule table:not(.weekend-day) select[id*="-duration-hours"]').val('8');
|
||||||
|
$('.day-schedule table:not(.weekend-day) select[id*="-duration-minutes"]').val('0');
|
||||||
|
$('.day-schedule input[id*="-enabled"]').prop('checked', true);
|
||||||
|
$('.day-schedule .weekend-day input[id*="-enabled"]').prop('checked', false);
|
||||||
|
break;
|
||||||
|
case 'weekend':
|
||||||
|
$('.day-schedule .weekend-day input[type="time"][id$="start-time"]').val('00:00')
|
||||||
|
$('.day-schedule .weekend-day select[id*="-duration-hours"]').val('24');
|
||||||
|
$('.day-schedule .weekend-day select[id*="-duration-minutes"]').val('0');
|
||||||
|
$('.day-schedule input[id*="-enabled"]').prop('checked', false);
|
||||||
|
$('.day-schedule .weekend-day input[id*="-enabled"]').prop('checked', true);
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
$('.day-schedule .day-schedule input[type="time"]').val('00:00')
|
||||||
|
$('.day-schedule .day-schedule select[id*="-duration-hours"]').val('24');
|
||||||
|
$('.day-schedule .day-schedule select[id*="-duration-minutes"]').val('0');
|
||||||
|
$('.day-schedule .day-schedule input[id*="-enabled"]').prop('checked', true);
|
||||||
|
break;
|
||||||
|
case 'once-per-day':
|
||||||
|
$('.day-schedule .day-schedule input[type="time"]').val('00:00')
|
||||||
|
$('.day-schedule .day-schedule select[id*="-duration-hours"]').val('24');
|
||||||
|
$('.day-schedule .day-schedule select[id*="-duration-minutes"]').val('0');
|
||||||
|
$('.day-schedule .day-schedule input[id*="-enabled"]').prop('checked', true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from flask import url_for
|
||||||
|
from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client
|
||||||
|
|
||||||
|
def test_setup(client, live_server):
|
||||||
|
live_server_setup(live_server)
|
||||||
|
|
||||||
|
def test_check_basic_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)
|
||||||
|
|
||||||
|
# We use "Pacific/Kiritimati" because its the furthest +14 hours, so it might show up more interesting bugs
|
||||||
|
# The rest of the actual functionality should be covered in the unit-test unit/test_scheduler.py
|
||||||
|
#####################
|
||||||
|
res = client.post(
|
||||||
|
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-fetch_backend': "html_requests"},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert b"Settings updated." in res.data
|
||||||
|
|
||||||
|
res = client.get(url_for("settings_page"))
|
||||||
|
assert b'Pacific/Kiritimati' in res.data
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
"time_schedule_limit-XXX-start_time": "00:00",
|
||||||
|
"time_schedule_limit-XXX-duration-hours": 24,
|
||||||
|
"time_schedule_limit-XXX-duration-minutes": 0,
|
||||||
|
"time_schedule_limit-XXX-enabled": '', # All days are turned off
|
||||||
|
"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
|
||||||
|
|
||||||
|
last_check = live_server.app.config['DATASTORE'].data['watching'][uuid]['last_checked']
|
||||||
|
data = {
|
||||||
|
"url": test_url,
|
||||||
|
"fetch_backend": "html_requests"
|
||||||
|
}
|
||||||
|
data.update(scheduler_data)
|
||||||
|
|
||||||
|
res = client.post(
|
||||||
|
url_for("edit_page", uuid="first"),
|
||||||
|
data=data,
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
|
||||||
|
# "Edit" should not trigger a check because it's not enabled in the schedule.
|
||||||
|
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()
|
||||||
|
live_server.app.config['DATASTORE'].data['watching'][uuid]["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)
|
||||||
|
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)
|
||||||
|
assert b'Deleted' in res.data
|
@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# run from dir above changedetectionio/ dir
|
||||||
|
# python3 -m unittest changedetectionio.tests.unit.test_jinja2_security
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
class TestScheduler(unittest.TestCase):
|
||||||
|
|
||||||
|
# UTC+14:00 (Line Islands, Kiribati) is the farthest ahead, always ahead of UTC.
|
||||||
|
# UTC-12:00 (Baker Island, Howland Island) is the farthest behind, always one calendar day behind UTC.
|
||||||
|
|
||||||
|
def test_timezone_basic_time_within_schedule(self):
|
||||||
|
from changedetectionio import time_handler
|
||||||
|
|
||||||
|
timezone_str = 'Europe/Berlin'
|
||||||
|
debug_datetime = datetime.now(ZoneInfo(timezone_str))
|
||||||
|
day_of_week = debug_datetime.strftime('%A')
|
||||||
|
time_str = str(debug_datetime.hour)+':00'
|
||||||
|
duration = 60 # minutes
|
||||||
|
|
||||||
|
# The current time should always be within 60 minutes of [time_hour]:00
|
||||||
|
result = time_handler.am_i_inside_time(day_of_week=day_of_week,
|
||||||
|
time_str=time_str,
|
||||||
|
timezone_str=timezone_str,
|
||||||
|
duration=duration)
|
||||||
|
|
||||||
|
self.assertEqual(result, True, f"{debug_datetime} is within time scheduler {day_of_week} {time_str} in {timezone_str} for {duration} minutes")
|
||||||
|
|
||||||
|
def test_timezone_basic_time_outside_schedule(self):
|
||||||
|
from changedetectionio import time_handler
|
||||||
|
|
||||||
|
timezone_str = 'Europe/Berlin'
|
||||||
|
# We try a date in the future..
|
||||||
|
debug_datetime = datetime.now(ZoneInfo(timezone_str))+ timedelta(days=-1)
|
||||||
|
day_of_week = debug_datetime.strftime('%A')
|
||||||
|
time_str = str(debug_datetime.hour) + ':00'
|
||||||
|
duration = 60*24 # minutes
|
||||||
|
|
||||||
|
# The current time should always be within 60 minutes of [time_hour]:00
|
||||||
|
result = time_handler.am_i_inside_time(day_of_week=day_of_week,
|
||||||
|
time_str=time_str,
|
||||||
|
timezone_str=timezone_str,
|
||||||
|
duration=duration)
|
||||||
|
|
||||||
|
self.assertNotEqual(result, True,
|
||||||
|
f"{debug_datetime} is NOT within time scheduler {day_of_week} {time_str} in {timezone_str} for {duration} minutes")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
@ -0,0 +1,105 @@
|
|||||||
|
from datetime import timedelta, datetime
|
||||||
|
from enum import IntEnum
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
|
class Weekday(IntEnum):
|
||||||
|
"""Enumeration for days of the week."""
|
||||||
|
Monday = 0
|
||||||
|
Tuesday = 1
|
||||||
|
Wednesday = 2
|
||||||
|
Thursday = 3
|
||||||
|
Friday = 4
|
||||||
|
Saturday = 5
|
||||||
|
Sunday = 6
|
||||||
|
|
||||||
|
|
||||||
|
def am_i_inside_time(
|
||||||
|
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.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
day_of_week (str): The day of the week (e.g., 'Monday').
|
||||||
|
time_str (str): The start time in 'HH:MM' format.
|
||||||
|
timezone_str (str): The timezone identifier (e.g., 'Europe/Berlin').
|
||||||
|
duration (int, optional): The duration of the time range in minutes. Default is 15.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the current time is within the time range, False otherwise.
|
||||||
|
"""
|
||||||
|
# Parse the target day of the week
|
||||||
|
try:
|
||||||
|
target_weekday = Weekday[day_of_week.capitalize()]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(f"Invalid day_of_week: '{day_of_week}'. Must be a valid weekday name.")
|
||||||
|
|
||||||
|
# Parse the start time
|
||||||
|
try:
|
||||||
|
target_time = datetime.strptime(time_str, '%H:%M').time()
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid time_str: '{time_str}'. Must be in 'HH:MM' format.")
|
||||||
|
|
||||||
|
# Define the timezone
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(timezone_str)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError(f"Invalid timezone_str: '{timezone_str}'. Must be a valid timezone identifier.")
|
||||||
|
|
||||||
|
# Get the current time in the specified timezone
|
||||||
|
now_tz = datetime.now(tz)
|
||||||
|
|
||||||
|
# Check if the current day matches the target day or overlaps due to duration
|
||||||
|
current_weekday = now_tz.weekday()
|
||||||
|
start_datetime_tz = datetime.combine(now_tz.date(), target_time, tzinfo=tz)
|
||||||
|
|
||||||
|
# Handle previous day's overlap
|
||||||
|
if target_weekday == (current_weekday - 1) % 7:
|
||||||
|
# Calculate start and end times for the overlap from the previous day
|
||||||
|
start_datetime_tz -= timedelta(days=1)
|
||||||
|
end_datetime_tz = start_datetime_tz + timedelta(minutes=duration)
|
||||||
|
if start_datetime_tz <= now_tz < end_datetime_tz:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Handle current day's range
|
||||||
|
if target_weekday == current_weekday:
|
||||||
|
end_datetime_tz = start_datetime_tz + timedelta(minutes=duration)
|
||||||
|
if start_datetime_tz <= now_tz < end_datetime_tz:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Handle next day's overlap
|
||||||
|
if target_weekday == (current_weekday + 1) % 7:
|
||||||
|
end_datetime_tz = start_datetime_tz + timedelta(minutes=duration)
|
||||||
|
if now_tz < start_datetime_tz and now_tz + timedelta(days=1) < end_datetime_tz:
|
||||||
|
return True
|
||||||
|
|
||||||
|
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
|
Loading…
Reference in new issue