diff --git a/app/db/scheduled_scripts.py b/app/db/scheduled_scripts.py index 6066cb5..f11c88c 100644 --- a/app/db/scheduled_scripts.py +++ b/app/db/scheduled_scripts.py @@ -1,8 +1,101 @@ +from datetime import datetime, timedelta from typing import Any from app.db.database import get_connection from app.db.migrations import run_migrations +# Plánovač (monitor) spouští pouze záznamy s `next_run_at IS NOT NULL` +# (a zároveň `next_run_at <= CURRENT_TIMESTAMP`). +# Portál proto MUSÍ `next_run_at` naplnit při create/update/enable, jinak se +# zapnutý skript nikdy nespustí. Monitor si po každém běhu sám dopočítá další +# `next_run_at` – Portál pouze připravuje data, scheduler zůstává v monitoru. +# +# Časy v DB jsou ukládané přes SQLite CURRENT_TIMESTAMP, tj. v UTC ve formátu +# "YYYY-MM-DD HH:MM:SS". `next_run_at` musíme počítat ve stejném formátu a +# časové zóně, aby porovnání v monitoru sedělo. +_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" + + +def _parse_schedule_time(schedule_time: str | None) -> tuple[int, int]: + """Rozparsuje "HH:MM" na (hodina, minuta) nebo vyhodí ValueError.""" + value = (schedule_time or "").strip() + if not value: + raise ValueError("Čas spuštění (schedule_time) je povinný pro tuto frekvenci") + parts = value.split(":") + if len(parts) != 2: + raise ValueError(f"Neplatný formát času: {value!r} (očekává se HH:MM)") + try: + hour = int(parts[0]) + minute = int(parts[1]) + except ValueError: + raise ValueError(f"Neplatný formát času: {value!r} (očekává se HH:MM)") + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError(f"Neplatný čas: {value!r} (hodina 0-23, minuta 0-59)") + return hour, minute + + +def _add_one_month(dt: datetime) -> datetime: + """Posune datum o jeden měsíc dopředu, ošetří kratší měsíce.""" + month = dt.month + 1 + year = dt.year + (month - 1) // 12 + month = (month - 1) % 12 + 1 + # Ošetření např. 31. -> kratší měsíc; den ořízneme na poslední platný. + day = dt.day + while True: + try: + return dt.replace(year=year, month=month, day=day) + except ValueError: + day -= 1 + if day < 1: + raise + + +def compute_next_run_at( + schedule_type: str | None, + schedule_time: str | None, + *, + now: datetime | None = None, +) -> str: + """Spočítá `next_run_at` stejně jako monitor. + + Vrací řetězec ve formátu "YYYY-MM-DD HH:MM:SS" v UTC. + Vyhodí ValueError, pokud čas nelze spočítat (neplatná frekvence/čas). + """ + now = (now or datetime.utcnow()).replace(microsecond=0) + + if schedule_type == "hourly": + # Další celá hodina. + nxt = now.replace(minute=0, second=0) + timedelta(hours=1) + return nxt.strftime(_TIMESTAMP_FORMAT) + + hour, minute = _parse_schedule_time(schedule_time) + candidate = now.replace(hour=hour, minute=minute, second=0) + + if schedule_type == "daily": + if candidate <= now: + candidate += timedelta(days=1) + elif schedule_type == "weekly": + if candidate <= now: + candidate += timedelta(days=7) + elif schedule_type == "monthly": + if candidate <= now: + candidate = _add_one_month(candidate) + else: + raise ValueError(f"Neznámá frekvence: {schedule_type!r}") + + return candidate.strftime(_TIMESTAMP_FORMAT) + + +def resolve_next_run_at(metadata: dict[str, Any]) -> str | None: + """Vrátí `next_run_at` pro zadaná metadata. + + - vypnutý skript (`is_enabled = 0`) => None + - zapnutý skript => spočítaný čas (může vyhodit ValueError) + """ + if not metadata.get("is_enabled"): + return None + return compute_next_run_at(metadata.get("schedule_type"), metadata.get("schedule_time")) + def get_scheduled_scripts(): run_migrations() @@ -73,6 +166,9 @@ def create_scheduled_script(metadata: dict[str, Any]) -> int: run_migrations() con = get_connection() + # Zapnutý skript musí dostat `next_run_at`, jinak ho monitor nikdy nespustí. + next_run_at = resolve_next_run_at(metadata) + cur = con.execute( """ INSERT INTO scheduled_scripts ( @@ -84,10 +180,11 @@ def create_scheduled_script(metadata: dict[str, Any]) -> int: timeout_seconds, is_enabled, is_running, + next_run_at, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) """, ( metadata.get("name"), @@ -97,6 +194,7 @@ def create_scheduled_script(metadata: dict[str, Any]) -> int: metadata.get("schedule_time") or None, metadata.get("timeout_seconds"), 1 if metadata.get("is_enabled") else 0, + next_run_at, ), ) script_id = cur.lastrowid @@ -109,6 +207,10 @@ def update_scheduled_script(script_id: int, metadata: dict[str, Any]) -> None: run_migrations() con = get_connection() + # Při editaci vždy přepočítáme `next_run_at` podle (případně změněné) + # frekvence/času. Vypnutý skript dostane NULL, zapnutý nový čas. + next_run_at = resolve_next_run_at(metadata) + con.execute( """ UPDATE scheduled_scripts @@ -118,6 +220,7 @@ def update_scheduled_script(script_id: int, metadata: dict[str, Any]) -> None: schedule_time = ?, timeout_seconds = ?, is_enabled = ?, + next_run_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, @@ -128,6 +231,7 @@ def update_scheduled_script(script_id: int, metadata: dict[str, Any]) -> None: metadata.get("schedule_time") or None, metadata.get("timeout_seconds"), 1 if metadata.get("is_enabled") else 0, + next_run_at, script_id, ), ) @@ -136,18 +240,22 @@ def update_scheduled_script(script_id: int, metadata: dict[str, Any]) -> None: con.close() -def set_scheduled_script_enabled(script_id: int, is_enabled: bool) -> None: +def set_scheduled_script_enabled( + script_id: int, is_enabled: bool, next_run_at: str | None +) -> None: run_migrations() con = get_connection() + # Zapnutí: dopočítaný `next_run_at`. Vypnutí: NULL. con.execute( """ UPDATE scheduled_scripts SET is_enabled = ?, + next_run_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, - (1 if is_enabled else 0, script_id), + (1 if is_enabled else 0, next_run_at if is_enabled else None, script_id), ) con.commit() diff --git a/app/routes/scheduled_scripts.py b/app/routes/scheduled_scripts.py index 02f699d..f7eef55 100644 --- a/app/routes/scheduled_scripts.py +++ b/app/routes/scheduled_scripts.py @@ -10,6 +10,7 @@ from app.db.audit import log_audit_event from app.db.jobs import create_job from app.logging_config import get_logger from app.db.scheduled_scripts import ( + compute_next_run_at, create_scheduled_script, delete_scheduled_script, get_scheduled_script, @@ -401,7 +402,12 @@ def create_scheduled_script_action( ): metadata = form_metadata(name, description, schedule_type, schedule_time, timeout_seconds, is_enabled) metadata["script_name"] = validate_script_name(script_name) - script_id = create_scheduled_script(metadata) + # Zapnutý skript nesmí skončit s next_run_at = NULL. Když čas nelze spočítat, + # ukaž adminovi chybu místo tichého uložení nespustitelného skriptu. + try: + script_id = create_scheduled_script(metadata) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Nelze spočítat další běh: {exc}") created_name = metadata["script_name"] if not script_path(created_name).exists(): save_script_file(created_name, DEFAULT_SCRIPT_CONTENT) @@ -519,7 +525,11 @@ def update_scheduled_script_action( if not script: raise HTTPException(status_code=404, detail="Scheduled script not found") metadata = form_metadata(name, description, schedule_type, schedule_time, timeout_seconds, is_enabled) - update_scheduled_script(script_id, metadata) + # Při editaci se přepočítává next_run_at; neplatnou kombinaci hlásíme adminovi. + try: + update_scheduled_script(script_id, metadata) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Nelze spočítat další běh: {exc}") log_audit_event( user, action="scheduled_script.updated", @@ -590,7 +600,16 @@ def toggle_scheduled_script(script_id: int, user=Depends(require_user)): if not script: raise HTTPException(status_code=404, detail="Scheduled script not found") enabled = not bool(script.get("is_enabled")) - set_scheduled_script_enabled(script_id, enabled) + # Zapnutí dopočítá next_run_at, vypnutí ho nastaví na NULL (řeší DB vrstva). + next_run_at = None + if enabled: + try: + next_run_at = compute_next_run_at( + script.get("schedule_type"), script.get("schedule_time") + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Nelze spočítat další běh: {exc}") + set_scheduled_script_enabled(script_id, enabled, next_run_at) log_audit_event( user, action="scheduled_script.enabled" if enabled else "scheduled_script.disabled",