Files
appfactory-portal/app/db/scheduled_scripts.py
T
2026-06-23 07:33:31 +02:00

281 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
con = get_connection()
rows = con.execute(
"""
SELECT
id,
name,
description,
script_name,
schedule_type,
schedule_time,
timeout_seconds,
COALESCE(is_enabled, 1) AS is_enabled,
COALESCE(is_running, 0) AS is_running,
last_run_at,
last_status,
last_job_id,
last_error_text,
next_run_at,
created_at,
updated_at
FROM scheduled_scripts
ORDER BY name
"""
).fetchall()
con.close()
return [dict(row) for row in rows]
def get_scheduled_script(script_id: int):
run_migrations()
con = get_connection()
row = con.execute(
"""
SELECT
id,
name,
description,
script_name,
schedule_type,
schedule_time,
timeout_seconds,
COALESCE(is_enabled, 1) AS is_enabled,
COALESCE(is_running, 0) AS is_running,
last_run_at,
last_status,
last_job_id,
last_error_text,
next_run_at,
created_at,
updated_at
FROM scheduled_scripts
WHERE id = ?
""",
(script_id,),
).fetchone()
con.close()
return dict(row) if row else None
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 (
name,
description,
script_name,
schedule_type,
schedule_time,
timeout_seconds,
is_enabled,
is_running,
next_run_at,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(
metadata.get("name"),
metadata.get("description") or None,
metadata.get("script_name"),
metadata.get("schedule_type"),
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
con.commit()
con.close()
return script_id
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
SET name = ?,
description = ?,
schedule_type = ?,
schedule_time = ?,
timeout_seconds = ?,
is_enabled = ?,
next_run_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
metadata.get("name"),
metadata.get("description") or None,
metadata.get("schedule_type"),
metadata.get("schedule_time") or None,
metadata.get("timeout_seconds"),
1 if metadata.get("is_enabled") else 0,
next_run_at,
script_id,
),
)
con.commit()
con.close()
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, next_run_at if is_enabled else None, script_id),
)
con.commit()
con.close()
def delete_scheduled_script(script_id: int) -> bool:
run_migrations()
con = get_connection()
cur = con.execute(
"""
DELETE FROM scheduled_scripts
WHERE id = ?
AND COALESCE(is_running, 0) = 0
""",
(script_id,),
)
deleted = cur.rowcount == 1
con.commit()
con.close()
return deleted