Schedule times were UTC, so a nightly 03:00 backup had to be entered as 01:00 or 02:00 depending on the season, and moved by an hour twice a year. Each schedule now has an IANA time zone, preset in the form to the browser's (e.g. Europe/Berlin), and its hour/minute/weekday are wall-clock time there. compute_next_run builds each candidate day from its date in that zone rather than adding 24 hours, so the time stays put across daylight saving changes; a time skipped by the spring change runs an hour later that day. next_run is still stored as naive UTC. - Existing schedules default to UTC and keep their run times until edited. - The API validates the zone and hour/minute/weekday ranges (400) instead of silently wrapping them. - tzdata is a dependency: the slim base image has no system tz database. Tests cover Berlin winter and summer time, both 2026 change days, the skipped hour, a weekly run across the UTC date line, hourly at a +5:30 offset, the UTC fallback and the API checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
4.2 KiB
Python
141 lines
4.2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from sqlmodel import Field, SQLModel
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
FREQUENCIES = ["hourly", "daily", "weekly"]
|
|
|
|
|
|
class BackupSchedule(SQLModel, table=True):
|
|
"""An automatic, recurring backup of local stacks to a destination.
|
|
|
|
Covers the stacks in ``stack_ids``, or every stack when ``all_stacks`` is
|
|
set (so new stacks are included without editing the schedule). Each stack
|
|
is backed up on its own, with its own file, retention and status.
|
|
"""
|
|
|
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
#: The single stack of schedules made before 0.65.0. Read through
|
|
#: stacks_of(); new rows leave it empty and use stack_ids.
|
|
stack_id: str = ""
|
|
stack_ids: str = "[]" # JSON list of stack ids
|
|
all_stacks: bool = False
|
|
destination_id: int
|
|
frequency: str = "daily" # one of FREQUENCIES
|
|
hour: int = 3 # wall-clock time in ``timezone``, used for daily/weekly
|
|
minute: int = 0
|
|
weekday: int = 0 # 0=Mon .. 6=Sun, used for weekly
|
|
#: IANA zone the time is in ("Europe/Berlin"). Schedules from before
|
|
#: 0.66.0 were UTC and stay so until edited.
|
|
timezone: str = "UTC"
|
|
include_volumes: bool = True
|
|
stop_first: bool = True
|
|
keep: int = 7 # retention: keep newest N for this stack on the dest (0 = all)
|
|
enabled: bool = True
|
|
last_run: Optional[datetime] = None
|
|
last_status: Optional[str] = None # "ok" | "warning: ..." | "error: ..."
|
|
#: JSON {stack_id: status} from the last run, for the per-stack view.
|
|
stack_status: str = "{}"
|
|
next_run: Optional[datetime] = None
|
|
created_at: datetime = Field(default_factory=_now)
|
|
|
|
|
|
def stacks_of(schedule: BackupSchedule) -> list[str]:
|
|
"""The explicitly chosen stacks (empty for an all-stacks schedule)."""
|
|
try:
|
|
ids = json.loads(schedule.stack_ids or "[]")
|
|
except ValueError:
|
|
ids = []
|
|
if not ids and schedule.stack_id:
|
|
ids = [schedule.stack_id] # a pre-0.65.0 row
|
|
return [str(i) for i in ids]
|
|
|
|
|
|
def set_stacks(schedule: BackupSchedule, ids: list[str]) -> None:
|
|
schedule.stack_ids = json.dumps(sorted(dict.fromkeys(ids)))
|
|
# Keep the legacy column meaningful for anything still reading it.
|
|
schedule.stack_id = ids[0] if len(ids) == 1 else ""
|
|
|
|
|
|
def status_of(schedule: BackupSchedule) -> dict:
|
|
try:
|
|
return json.loads(schedule.stack_status or "{}")
|
|
except ValueError:
|
|
return {}
|
|
|
|
|
|
def describe_stacks(schedule: BackupSchedule) -> str:
|
|
if schedule.all_stacks:
|
|
return "all stacks"
|
|
return ", ".join(stacks_of(schedule)) or "no stacks"
|
|
|
|
|
|
# --- API schemas ---
|
|
|
|
|
|
class ScheduleCreate(SQLModel):
|
|
stack_ids: list[str] = []
|
|
all_stacks: bool = False
|
|
#: Accepted for API clients from before multi-stack schedules.
|
|
stack_id: Optional[str] = None
|
|
destination_id: int
|
|
frequency: str = "daily"
|
|
hour: int = 3
|
|
minute: int = 0
|
|
weekday: int = 0
|
|
timezone: str = "UTC"
|
|
include_volumes: bool = True
|
|
stop_first: bool = True
|
|
keep: int = 7
|
|
enabled: bool = True
|
|
|
|
|
|
class ScheduleUpdate(SQLModel):
|
|
stack_ids: Optional[list[str]] = None
|
|
all_stacks: Optional[bool] = None
|
|
destination_id: Optional[int] = None
|
|
frequency: Optional[str] = None
|
|
hour: Optional[int] = None
|
|
minute: Optional[int] = None
|
|
weekday: Optional[int] = None
|
|
timezone: Optional[str] = None
|
|
include_volumes: Optional[bool] = None
|
|
stop_first: Optional[bool] = None
|
|
keep: Optional[int] = None
|
|
enabled: Optional[bool] = None
|
|
|
|
|
|
class ScheduleRead(SQLModel):
|
|
id: int
|
|
stack_ids: list[str]
|
|
all_stacks: bool
|
|
#: The one stack when there is exactly one, else "" (older API clients).
|
|
stack_id: str
|
|
destination_id: int
|
|
destination_name: Optional[str]
|
|
frequency: str
|
|
hour: int
|
|
minute: int
|
|
weekday: int
|
|
timezone: str
|
|
include_volumes: bool
|
|
stop_first: bool
|
|
keep: int
|
|
enabled: bool
|
|
last_run: Optional[datetime]
|
|
last_status: Optional[str]
|
|
#: {stack_id: status} from the last run.
|
|
stack_status: dict
|
|
#: A run is in progress right now.
|
|
running: bool
|
|
next_run: Optional[datetime]
|
|
created_at: datetime
|