Files
pobsync/src/pobsync_backend/scheduler.py
Peter van Arkel 86eee0f916 (feature) Show next scheduled run and backup run type in the UI
Add a scheduler helper that calculates the next due time for a cron-style
schedule expression and surface that value on the dashboard and host detail
pages.

Show the latest run type in host summaries and backup trend tables so
manual and scheduled backups are distinguishable in the Django UI.

Keep the calculation derived from existing ScheduleConfig data without
adding a migration.
2026-05-19 22:57:58 +02:00

100 lines
2.9 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
@dataclass(frozen=True)
class CronSchedule:
minute: str
hour: str
day_of_month: str
month: str
day_of_week: str
def parse_cron_expr(expr: str) -> CronSchedule:
parts = expr.strip().split()
if len(parts) != 5:
raise ValueError("cron expression must have exactly 5 fields")
return CronSchedule(*parts)
def due_key(moment: datetime) -> str:
return moment.strftime("%Y%m%d%H%M")
def is_due(expr: str, moment: datetime) -> bool:
schedule = parse_cron_expr(expr)
cron_dow = (moment.weekday() + 1) % 7
return (
_field_matches(schedule.minute, moment.minute, 0, 59)
and _field_matches(schedule.hour, moment.hour, 0, 23)
and _field_matches(schedule.day_of_month, moment.day, 1, 31)
and _field_matches(schedule.month, moment.month, 1, 12)
and _field_matches(schedule.day_of_week, cron_dow, 0, 7, sunday_alias=True)
)
def next_due_after(expr: str, moment: datetime, *, max_days: int = 366) -> datetime | None:
parse_cron_expr(expr)
candidate = moment.replace(second=0, microsecond=0) + timedelta(minutes=1)
deadline = candidate + timedelta(days=max_days)
while candidate <= deadline:
if is_due(expr, candidate):
return candidate
candidate += timedelta(minutes=1)
return None
def _field_matches(field: str, value: int, min_value: int, max_value: int, sunday_alias: bool = False) -> bool:
for part in field.split(","):
if _part_matches(part.strip(), value, min_value, max_value, sunday_alias=sunday_alias):
return True
return False
def _part_matches(part: str, value: int, min_value: int, max_value: int, sunday_alias: bool) -> bool:
if not part:
return False
step = 1
base = part
if "/" in part:
base, step_s = part.split("/", 1)
if not step_s.isdigit():
return False
step = int(step_s)
if step <= 0:
return False
if base == "*":
start = min_value
end = max_value
elif "-" in base:
start_s, end_s = base.split("-", 1)
if not start_s.isdigit() or not end_s.isdigit():
return False
start = int(start_s)
end = int(end_s)
elif base.isdigit():
start = end = int(base)
else:
return False
values = _normalize_values(range(start, end + 1), sunday_alias=sunday_alias)
normalized_value = 0 if sunday_alias and value == 7 else value
if normalized_value not in values:
return False
return (normalized_value - min(values)) % step == 0
def _normalize_values(values: range, sunday_alias: bool) -> set[int]:
out: set[int] = set()
for value in values:
if sunday_alias and value == 7:
out.add(0)
else:
out.add(value)
return out