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 # UTC, used for daily/weekly minute: int = 0 weekday: int = 0 # 0=Mon .. 6=Sun, used for weekly 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 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 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 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