Added support for cron schedules in backend (#83)

Fully functioning backend (and API) support for cron schedules. Frontend is coming later.
This commit is contained in:
CasVT
2025-05-04 18:02:49 +02:00
parent 9a91955ff4
commit a5ec5c5427
12 changed files with 159 additions and 19 deletions

View File

@@ -7,6 +7,7 @@ repos:
additional_dependencies: [
apprise ~= 1.4,
python-dateutil ~= 2.8,
cron-converter ~= 1.2,
Flask ~= 3.0,
waitress ~= 2.1
]
@@ -22,6 +23,7 @@ repos:
apprise ~= 1.4,
python-dateutil ~= 2.8,
cron-converter ~= 1.2,
Flask ~= 3.0,
waitress ~= 2.1
]
@@ -34,6 +36,7 @@ repos:
additional_dependencies: [
apprise ~= 1.4,
python-dateutil ~= 2.8,
cron-converter ~= 1.2,
Flask ~= 3.0,
waitress ~= 2.1
]
@@ -47,6 +50,7 @@ repos:
additional_dependencies: [
apprise ~= 1.4,
python-dateutil ~= 2.8,
cron-converter ~= 1.2,
Flask ~= 3.0,
waitress ~= 2.1
]

View File

@@ -248,6 +248,7 @@ class ReminderData(GeneralReminderData):
repeat_quantity: Union[str, None]
repeat_interval: Union[int, None]
_weekdays: Union[str, None]
cron_schedule: Union[str, None]
enabled: bool
def __post_init__(self) -> None:

View File

@@ -17,6 +17,7 @@ from typing import (Any, Callable, Generator, Iterable,
List, Sequence, Set, Tuple, Union, cast)
from apprise import Apprise, LogCapture
from cron_converter import Cron
from dateutil.relativedelta import relativedelta
from backend.base.definitions import (WEEKDAY_NUMBER, GeneralReminderData,
@@ -240,7 +241,8 @@ def find_next_time(
original_time: int,
repeat_quantity: Union[RepeatQuantity, None],
repeat_interval: Union[int, None],
weekdays: Union[List[WEEKDAY_NUMBER], None]
weekdays: Union[List[WEEKDAY_NUMBER], None],
cron_schedule: Union[str, None]
) -> int:
"""Calculate the next timestep based on original time and repeat/interval
values.
@@ -256,6 +258,8 @@ def find_next_time(
weekdays (Union[List[WEEKDAY_NUMBER], None]): If set, on which days the
time can continue. Monday is 0, Sunday is 6.
cron_schedule (Union[str, None]): If set, the cron schedule to follow.
Returns:
int: The next timestamp in the future.
"""
@@ -266,7 +270,14 @@ def find_next_time(
original_datetime = datetime.fromtimestamp(original_time)
new_time = datetime.fromtimestamp(original_time)
if (
if cron_schedule is not None:
cron_instance = Cron(cron_schedule)
schedule = cron_instance.schedule(current_time)
new_time = schedule.next()
while new_time <= current_time:
new_time = schedule.next()
elif (
repeat_quantity is not None
and repeat_interval is not None
):

View File

@@ -61,8 +61,9 @@ class ReminderHandler(metaclass=Singleton):
elif (
reminder.repeat_quantity,
reminder.weekdays
) == (None, None):
reminder.weekdays,
reminder.cron_schedule
) == (None, None, None):
# Delete the reminder from the database
self.reminder_db.delete(reminder.id)
@@ -75,7 +76,8 @@ class ReminderHandler(metaclass=Singleton):
lambda q: RepeatQuantity(q)
),
reminder.repeat_interval,
reminder.weekdays
reminder.weekdays,
reminder.cron_schedule
)
self.reminder_db.update(reminder.id, new_time)

View File

@@ -57,6 +57,7 @@ class Reminder:
repeat_quantity: Union[None, RepeatQuantity] = None,
repeat_interval: Union[None, int] = None,
weekdays: Union[None, List[WEEKDAY_NUMBER]] = None,
cron_schedule: Union[None, str] = None,
color: Union[None, str] = None,
enabled: Union[None, bool] = None
) -> ReminderData:
@@ -89,6 +90,10 @@ class Reminder:
indexes of the days of the week that the reminder should run.
Defaults to None.
cron_schedule (Union[None, str], optional): The new cron schedule
that the reminder should run on.
Defaults to None.
color (Union[None, str], optional): The new hex code of the color
of the reminder, which is shown in the web-ui.
Defaults to None.
@@ -112,11 +117,18 @@ class Reminder:
LOGGER.info(
f'Updating notification service {self.id}: '
+ f'{title=}, {time=}, {notification_services=}, {text=}, '
+ f'{repeat_quantity=}, {repeat_interval=}, {weekdays=}, {color=}, '
+ f"{enabled=}"
+ f'{repeat_quantity=}, {repeat_interval=}, '
+ f'{weekdays=}, {cron_schedule=}, '
+ f'{color=}, {enabled=}'
)
# Validate data
if cron_schedule is not None and (
repeat_quantity is not None
or repeat_interval is not None
or weekdays is not None
):
raise InvalidKeyValue('cron_schedule', cron_schedule)
if repeat_quantity is None and repeat_interval is not None:
raise InvalidKeyValue('repeat_quantity', repeat_quantity)
elif repeat_quantity is not None and repeat_interval is None:
@@ -127,6 +139,7 @@ class Reminder:
repeated_reminder = (
(repeat_quantity is not None and repeat_interval is not None)
or weekdays is not None
or cron_schedule is not None
)
if time is not None:
@@ -156,13 +169,18 @@ class Reminder:
weekdays,
lambda w: ",".join(map(str, sorted(w)))
),
'cron_schedule': cron_schedule,
'color': color,
'notification_services': notification_services,
'enabled': enabled
}
for k, v in new_values.items():
if (
k in ('repeat_quantity', 'repeat_interval', 'weekdays', 'color')
k in (
'repeat_quantity', 'repeat_interval',
'weekdays', 'cron_schedule',
'color'
)
or v is not None
):
data[k] = v
@@ -172,7 +190,8 @@ class Reminder:
data["time"],
repeat_quantity,
data["repeat_interval"],
weekdays
weekdays,
cron_schedule
)
self.reminder_db.update(
self.id,
@@ -182,6 +201,7 @@ class Reminder:
data["repeat_quantity"],
data["repeat_interval"],
data["weekdays"],
data["cron_schedule"],
data["time"],
data["color"],
data["notification_services"],
@@ -198,6 +218,7 @@ class Reminder:
data["repeat_quantity"],
data["repeat_interval"],
data["weekdays"],
data["cron_schedule"],
data["original_time"],
data["color"],
data["notification_services"],
@@ -290,6 +311,7 @@ class Reminders:
repeat_quantity: Union[None, RepeatQuantity] = None,
repeat_interval: Union[None, int] = None,
weekdays: Union[None, List[WEEKDAY_NUMBER]] = None,
cron_schedule: Union[None, str] = None,
color: Union[None, str] = None,
enabled: bool = True
) -> Reminder:
@@ -318,6 +340,10 @@ class Reminders:
of the days of the week that the reminder should run.
Defaults to None.
cron_schedule (Union[None, str], optional): The cron schedule that
the reminder should run on.
Defaults to None.
color (Union[None, str], optional): The hex code of the color of the
reminder, which is shown in the web-ui.
Defaults to None.
@@ -328,7 +354,7 @@ class Reminders:
Note about args:
Either repeat_quantity and repeat_interval are given,
weekdays is given or neither, but not both.
weekdays is given, cron_schedule is given or none.
Raises:
NotificationServiceNotFound: One of the notification services was
@@ -341,14 +367,20 @@ class Reminders:
"""
LOGGER.info(
f'Adding reminder with {title=}, {time=}, {notification_services=}, ' +
f'{text=}, {repeat_quantity=}, {repeat_interval=}, {weekdays=}, {color=}' +
f'{enabled=}')
f'{text=}, {repeat_quantity=}, {repeat_interval=}, {weekdays=}, ' +
f'{cron_schedule=}, {color=}, {enabled=}')
# Validate data
if time < datetime.utcnow().timestamp():
raise InvalidTime(time)
time = round(time)
if cron_schedule is not None and (
repeat_quantity is not None
or repeat_interval is not None
or weekdays is not None
):
raise InvalidKeyValue('cron_schedule', cron_schedule)
if repeat_quantity is None and repeat_interval is not None:
raise InvalidKeyValue('repeat_quantity', repeat_quantity)
elif repeat_quantity is not None and repeat_interval is None:
@@ -365,13 +397,14 @@ class Reminders:
NotificationService(self.user_id, ns)
# Prepare args
if any((repeat_quantity, weekdays)):
if any((repeat_quantity, weekdays, cron_schedule)):
original_time = time
time = find_next_time(
original_time,
repeat_quantity,
repeat_interval,
weekdays
weekdays,
cron_schedule
)
else:
original_time = None
@@ -390,6 +423,7 @@ class Reminders:
time, repeat_quantity_str,
repeat_interval,
weekdays_str,
cron_schedule,
original_time,
color,
notification_services,

View File

@@ -12,6 +12,7 @@ from typing import Union
from backend.base.definitions import Constants
from backend.base.helpers import Singleton
from backend.base.logging import LOGGER
from backend.features.reminder_handler import ReminderHandler
from backend.internals.db_models import UserlessRemindersDB
from backend.internals.server import Server
from backend.internals.settings import Settings
@@ -72,6 +73,7 @@ class TimezoneChangeHandler(metaclass=Singleton):
offset=shift_delta
)
settings.update({"measured_timezone": current_timezone})
ReminderHandler().find_next_reminder()
LOGGER.info(
"Detected timezone/DST change (%s to %s), shifted reminders",
measured_timezone, current_timezone

View File

@@ -311,6 +311,7 @@ def setup_db() -> None:
repeat_interval INTEGER,
original_time INTEGER,
weekdays VARCHAR(13),
cron_schedule VARCHAR(255),
color VARCHAR(7),
enabled BOOL NOT NULL DEFAULT 1,

View File

@@ -334,3 +334,52 @@ class MigrateSetDBBackupFolder(DBMigrator):
s.update({"db_backup_folder": SettingsValues.db_backup_folder})
return
class MigrateAddCronScheduleColumn(DBMigrator):
start_version = 12
def run(self) -> None:
# V12 -> V13
from backend.internals.db import get_db
get_db().executescript("""
BEGIN TRANSACTION;
PRAGMA defer_foreign_keys = ON;
CREATE TEMPORARY TABLE temp_reminders_13 AS
SELECT * FROM reminders;
DROP TABLE reminders;
CREATE TABLE IF NOT EXISTS reminders(
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
text TEXT,
time INTEGER NOT NULL,
repeat_quantity VARCHAR(15),
repeat_interval INTEGER,
original_time INTEGER,
weekdays VARCHAR(13),
cron_schedule VARCHAR(255),
color VARCHAR(7),
enabled BOOL NOT NULL DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id)
);
INSERT INTO reminders
SELECT
id, user_id,
title, text, time,
repeat_quantity, repeat_interval,
original_time, weekdays,
NULL AS cron_schedule,
color, enabled
FROM temp_reminders_13;
COMMIT;
""")

View File

@@ -547,7 +547,8 @@ class RemindersDB:
id, title, text, color,
time, original_time,
repeat_quantity, repeat_interval,
weekdays AS _weekdays, enabled
weekdays AS _weekdays, cron_schedule,
enabled
FROM reminders
WHERE user_id = :user_id
{id_filter};
@@ -574,6 +575,7 @@ class RemindersDB:
repeat_quantity: Union[str, None],
repeat_interval: Union[int, None],
weekdays: Union[str, None],
cron_schedule: Union[str, None],
original_time: Union[int, None],
color: Union[str, None],
notification_services: List[int],
@@ -585,7 +587,7 @@ class RemindersDB:
title, text,
time,
repeat_quantity, repeat_interval,
weekdays,
weekdays, cron_schedule,
original_time,
color,
enabled
@@ -595,7 +597,7 @@ class RemindersDB:
:title, :text,
:time,
:rq, :ri,
:wd,
:wd, :cs,
:ot,
:color,
:enabled
@@ -609,6 +611,7 @@ class RemindersDB:
"rq": repeat_quantity,
"ri": repeat_interval,
"wd": weekdays,
"cs": cron_schedule,
"ot": original_time,
"color": color,
"enabled": enabled
@@ -630,6 +633,7 @@ class RemindersDB:
repeat_quantity: Union[str, None],
repeat_interval: Union[int, None],
weekdays: Union[str, None],
cron_schedule: Union[str, None],
original_time: Union[int, None],
color: Union[str, None],
notification_services: List[int],
@@ -644,6 +648,7 @@ class RemindersDB:
repeat_quantity = :rq,
repeat_interval = :ri,
weekdays = :wd,
cron_schedule = :cs,
original_time = :ot,
color = :color,
enabled = :enabled
@@ -656,6 +661,7 @@ class RemindersDB:
"rq": repeat_quantity,
"ri": repeat_interval,
"wd": weekdays,
"cs": cron_schedule,
"ot": original_time,
"color": color,
"enabled": enabled,
@@ -731,7 +737,8 @@ class UserlessRemindersDB:
title, text, color,
time, original_time,
repeat_quantity, repeat_interval,
weekdays AS _weekdays, enabled
weekdays AS _weekdays, cron_schedule,
enabled
FROM reminders
{time_filter};
""",
@@ -757,6 +764,7 @@ class UserlessRemindersDB:
repeat_quantity: Union[str, None],
repeat_interval: Union[int, None],
weekdays: Union[str, None],
cron_schedule: Union[str, None],
original_time: Union[int, None],
color: Union[str, None],
notification_services: List[int],
@@ -769,6 +777,7 @@ class UserlessRemindersDB:
time,
repeat_quantity, repeat_interval,
weekdays,
cron_schedule,
original_time,
color,
enabled
@@ -779,6 +788,7 @@ class UserlessRemindersDB:
:time,
:rq, :ri,
:wd,
:cs,
:ot,
:color,
:enabled
@@ -792,6 +802,7 @@ class UserlessRemindersDB:
"rq": repeat_quantity,
"ri": repeat_interval,
"wd": weekdays,
"cs": cron_schedule,
"ot": original_time,
"color": color,
"enabled": enabled

View File

@@ -308,6 +308,7 @@ def api_reminders_list(inputs: Dict[str, Any]):
repeat_quantity=inputs['repeat_quantity'],
repeat_interval=inputs['repeat_interval'],
weekdays=inputs['weekdays'],
cron_schedule=inputs['cron_schedule'],
color=inputs['color'],
enabled=inputs['enabled']
)
@@ -347,7 +348,6 @@ def api_get_reminder(inputs: Dict[str, Any], r_id: int):
return return_api(result.todict())
elif request.method == 'PUT':
print(inputs)
result = reminders.fetchone(r_id).update(
title=inputs['title'],
time=inputs['time'],
@@ -356,6 +356,7 @@ def api_get_reminder(inputs: Dict[str, Any], r_id: int):
repeat_quantity=inputs['repeat_quantity'],
repeat_interval=inputs['repeat_interval'],
weekdays=inputs['weekdays'],
cron_schedule=inputs['cron_schedule'],
color=inputs['color'],
enabled=inputs['enabled']
)

View File

@@ -12,6 +12,7 @@ from re import compile
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Tuple, Type, Union
from apprise import Apprise
from cron_converter import Cron
from flask import Blueprint, Request, request
from backend.base.custom_exceptions import (AccessUnauthorized,
@@ -295,6 +296,26 @@ class WeekDaysVariable(NonRequiredInputVariable):
)
class CronScheduleVariable(NonRequiredInputVariable):
name = "cron_schedule"
description = "The cron schedule that the reminder should run on"
def validate(self) -> bool:
if self.value is None:
return True
if not isinstance(self.value, str):
return False
try:
Cron(self.value)
except (TypeError, ValueError):
return False
return True
class ColorVariable(NonRequiredInputVariable):
name = "color"
description = "The hex code of the color of the entry, which is shown in the web-UI"
@@ -588,6 +609,7 @@ class RemindersData(EndpointData):
RepeatQuantityVariable,
RepeatIntervalVariable,
WeekDaysVariable,
CronScheduleVariable,
ColorVariable,
EnabledVariable
]
@@ -621,6 +643,7 @@ class ReminderData(EndpointData):
RepeatQuantityVariable,
RepeatIntervalVariable,
WeekDaysVariable,
CronScheduleVariable,
ColorVariable,
EditEnabledVariable
]

View File

@@ -1,4 +1,5 @@
apprise ~= 1.4
python-dateutil ~= 2.8
cron-converter ~= 1.2
Flask ~= 3.0
waitress ~= 2.1