scheduled fix
This commit is contained in:
+111
-3
@@ -1,8 +1,101 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.db.database import get_connection
|
from app.db.database import get_connection
|
||||||
from app.db.migrations import run_migrations
|
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():
|
def get_scheduled_scripts():
|
||||||
run_migrations()
|
run_migrations()
|
||||||
@@ -73,6 +166,9 @@ def create_scheduled_script(metadata: dict[str, Any]) -> int:
|
|||||||
run_migrations()
|
run_migrations()
|
||||||
con = get_connection()
|
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(
|
cur = con.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO scheduled_scripts (
|
INSERT INTO scheduled_scripts (
|
||||||
@@ -84,10 +180,11 @@ def create_scheduled_script(metadata: dict[str, Any]) -> int:
|
|||||||
timeout_seconds,
|
timeout_seconds,
|
||||||
is_enabled,
|
is_enabled,
|
||||||
is_running,
|
is_running,
|
||||||
|
next_run_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
metadata.get("name"),
|
metadata.get("name"),
|
||||||
@@ -97,6 +194,7 @@ def create_scheduled_script(metadata: dict[str, Any]) -> int:
|
|||||||
metadata.get("schedule_time") or None,
|
metadata.get("schedule_time") or None,
|
||||||
metadata.get("timeout_seconds"),
|
metadata.get("timeout_seconds"),
|
||||||
1 if metadata.get("is_enabled") else 0,
|
1 if metadata.get("is_enabled") else 0,
|
||||||
|
next_run_at,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
script_id = cur.lastrowid
|
script_id = cur.lastrowid
|
||||||
@@ -109,6 +207,10 @@ def update_scheduled_script(script_id: int, metadata: dict[str, Any]) -> None:
|
|||||||
run_migrations()
|
run_migrations()
|
||||||
con = get_connection()
|
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(
|
con.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE scheduled_scripts
|
UPDATE scheduled_scripts
|
||||||
@@ -118,6 +220,7 @@ def update_scheduled_script(script_id: int, metadata: dict[str, Any]) -> None:
|
|||||||
schedule_time = ?,
|
schedule_time = ?,
|
||||||
timeout_seconds = ?,
|
timeout_seconds = ?,
|
||||||
is_enabled = ?,
|
is_enabled = ?,
|
||||||
|
next_run_at = ?,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
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("schedule_time") or None,
|
||||||
metadata.get("timeout_seconds"),
|
metadata.get("timeout_seconds"),
|
||||||
1 if metadata.get("is_enabled") else 0,
|
1 if metadata.get("is_enabled") else 0,
|
||||||
|
next_run_at,
|
||||||
script_id,
|
script_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -136,18 +240,22 @@ def update_scheduled_script(script_id: int, metadata: dict[str, Any]) -> None:
|
|||||||
con.close()
|
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()
|
run_migrations()
|
||||||
con = get_connection()
|
con = get_connection()
|
||||||
|
|
||||||
|
# Zapnutí: dopočítaný `next_run_at`. Vypnutí: NULL.
|
||||||
con.execute(
|
con.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE scheduled_scripts
|
UPDATE scheduled_scripts
|
||||||
SET is_enabled = ?,
|
SET is_enabled = ?,
|
||||||
|
next_run_at = ?,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
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()
|
con.commit()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.db.audit import log_audit_event
|
|||||||
from app.db.jobs import create_job
|
from app.db.jobs import create_job
|
||||||
from app.logging_config import get_logger
|
from app.logging_config import get_logger
|
||||||
from app.db.scheduled_scripts import (
|
from app.db.scheduled_scripts import (
|
||||||
|
compute_next_run_at,
|
||||||
create_scheduled_script,
|
create_scheduled_script,
|
||||||
delete_scheduled_script,
|
delete_scheduled_script,
|
||||||
get_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 = form_metadata(name, description, schedule_type, schedule_time, timeout_seconds, is_enabled)
|
||||||
metadata["script_name"] = validate_script_name(script_name)
|
metadata["script_name"] = validate_script_name(script_name)
|
||||||
|
# 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)
|
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"]
|
created_name = metadata["script_name"]
|
||||||
if not script_path(created_name).exists():
|
if not script_path(created_name).exists():
|
||||||
save_script_file(created_name, DEFAULT_SCRIPT_CONTENT)
|
save_script_file(created_name, DEFAULT_SCRIPT_CONTENT)
|
||||||
@@ -519,7 +525,11 @@ def update_scheduled_script_action(
|
|||||||
if not script:
|
if not script:
|
||||||
raise HTTPException(status_code=404, detail="Scheduled script not found")
|
raise HTTPException(status_code=404, detail="Scheduled script not found")
|
||||||
metadata = form_metadata(name, description, schedule_type, schedule_time, timeout_seconds, is_enabled)
|
metadata = form_metadata(name, description, schedule_type, schedule_time, timeout_seconds, is_enabled)
|
||||||
|
# Při editaci se přepočítává next_run_at; neplatnou kombinaci hlásíme adminovi.
|
||||||
|
try:
|
||||||
update_scheduled_script(script_id, metadata)
|
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(
|
log_audit_event(
|
||||||
user,
|
user,
|
||||||
action="scheduled_script.updated",
|
action="scheduled_script.updated",
|
||||||
@@ -590,7 +600,16 @@ def toggle_scheduled_script(script_id: int, user=Depends(require_user)):
|
|||||||
if not script:
|
if not script:
|
||||||
raise HTTPException(status_code=404, detail="Scheduled script not found")
|
raise HTTPException(status_code=404, detail="Scheduled script not found")
|
||||||
enabled = not bool(script.get("is_enabled"))
|
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(
|
log_audit_event(
|
||||||
user,
|
user,
|
||||||
action="scheduled_script.enabled" if enabled else "scheduled_script.disabled",
|
action="scheduled_script.enabled" if enabled else "scheduled_script.disabled",
|
||||||
|
|||||||
Reference in New Issue
Block a user