scheduled fix

This commit is contained in:
JiriUhlir
2026-06-23 07:33:31 +02:00
parent a7a53f064c
commit 44a36ffb20
2 changed files with 133 additions and 6 deletions
+111 -3
View File
@@ -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()
+22 -3
View File
@@ -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",