fix(scheduler): honor app timezone for cron triggers and log scheduled events

CronTrigger.from_crontab was constructed without a timezone, so a cron like
'0 9 * * *' fired at 09:00 host-local instead of 09:00 in the admin-configured
timezone. Now all tracker/action cron triggers are built with the app tz, and
the setting endpoint rebuilds existing cron jobs when the tz changes (since
CronTrigger freezes its tz at construction time).

The scheduler provider also renders current_date/time/datetime/weekday in the
configured tz and exposes a new 'timezone' template variable.

EventLog entries for scheduled_message now include schedule_type,
cron_expression/interval_seconds, timezone, and fire_count, and the dashboard
shows the event type with a label/icon/color.
This commit is contained in:
2026-04-23 13:35:49 +03:00
parent 5604c733d1
commit 1024085cdd
10 changed files with 209 additions and 17 deletions
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
@@ -57,6 +58,13 @@ SCHEDULER_VARIABLES: list[TemplateVariableDefinition] = [
example="Monday",
provider_type=ServiceProviderType.SCHEDULER,
),
TemplateVariableDefinition(
name="timezone",
type="string",
description="IANA timezone used to compute current_date/time",
example="Europe/Warsaw",
provider_type=ServiceProviderType.SCHEDULER,
),
TemplateVariableDefinition(
name="custom_vars",
type="dict",
@@ -83,7 +91,8 @@ class SchedulerServiceProvider(ServiceProvider):
custom_variables: dict[str, str] | None = None,
date_format: str = "%d.%m.%Y",
time_format: str = "%H:%M",
datetime_format: str = "%d.%m.%Y, %H:%M UTC",
datetime_format: str = "%d.%m.%Y, %H:%M %Z",
timezone_name: str | None = None,
) -> None:
self._name = name
self._tracker_name = tracker_name
@@ -91,6 +100,18 @@ class SchedulerServiceProvider(ServiceProvider):
self._date_format = date_format
self._time_format = time_format
self._datetime_format = datetime_format
# Resolve a timezone for date/time rendering. Falls back to UTC on
# invalid IANA names so a typo in app settings doesn't break polls.
tz: ZoneInfo
if timezone_name:
try:
tz = ZoneInfo(timezone_name)
except (ZoneInfoNotFoundError, ValueError):
_LOGGER.warning("Unknown timezone %r; falling back to UTC", timezone_name)
tz = ZoneInfo("UTC")
else:
tz = ZoneInfo("UTC")
self._tz = tz
async def connect(self) -> bool:
return True # virtual provider — always connected
@@ -103,7 +124,8 @@ class SchedulerServiceProvider(ServiceProvider):
collection_ids: list[str],
tracker_state: dict[str, Any],
) -> tuple[list[ServiceEvent], dict[str, Any]]:
now = datetime.now(timezone.utc)
now_utc = datetime.now(timezone.utc)
now = now_utc.astimezone(self._tz)
# State uses {collection_id: {dict}} convention like other providers
sched_state = tracker_state.get("scheduler", {})
fire_count = sched_state.get("fire_count", 0) + 1
@@ -115,6 +137,7 @@ class SchedulerServiceProvider(ServiceProvider):
"current_time": now.strftime(self._time_format),
"current_datetime": now.strftime(self._datetime_format),
"weekday": _WEEKDAYS[now.weekday()],
"timezone": self._tz.key,
"custom_vars": dict(self._custom_variables),
}
# Flatten custom variables at top level for easy template access
@@ -224,6 +224,7 @@ def build_template_context(
ctx.setdefault("current_time", event.extra.get("current_time", ""))
ctx.setdefault("current_datetime", event.extra.get("current_datetime", ""))
ctx.setdefault("weekday", event.extra.get("weekday", ""))
ctx.setdefault("timezone", event.extra.get("timezone", "UTC"))
ctx.setdefault("custom_vars", event.extra.get("custom_vars", {}))
return ctx