Files
daily-blackout-check/src/blackout_notifier/service.py
T
Meghdad ea03d84901
Test blackout notifier / test (push) Successful in 4s
Hourly blackout check / check (push) Successful in 5s
style: apply Ruff formatting
2026-07-22 03:20:01 +03:30

175 lines
6.2 KiB
Python

from __future__ import annotations
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
from blackout_notifier.clients import ExternalServiceError, Notifier, SaapaClient
from blackout_notifier.messages import (
cancelled_outage_message,
new_outage_message,
reactivated_outage_message,
updated_outage_message,
)
from blackout_notifier.models import Outage, OutageDataError, jalali_query_window
from blackout_notifier.state import StateRepository
LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class RunResult:
sent: int = 0
state_changed: bool = False
errors: list[str] = field(default_factory=list)
class OutageService:
def __init__(
self,
*,
bill_chat_ids: Mapping[str, tuple[str, ...]],
lookahead_days: int,
saapa: SaapaClient,
notifier: Notifier,
state_repository: StateRepository,
) -> None:
self._bill_chat_ids = dict(bill_chat_ids)
self._lookahead_days = lookahead_days
self._saapa = saapa
self._notifier = notifier
self._state_repository = state_repository
def run(self, now: datetime, *, persist: bool = True) -> RunResult:
state = self._state_repository.load()
result = RunResult()
from_date, to_date = jalali_query_window(now, self._lookahead_days)
for bill_id, chat_ids in self._bill_chat_ids.items():
try:
outages = self._saapa.fetch(bill_id, from_date, to_date)
except ExternalServiceError as error:
result.errors.append(str(error))
LOGGER.error("%s", error)
continue
self._process_bill(state, bill_id, chat_ids, outages, now, result)
if self._prune_expired(state, now):
result.state_changed = True
if result.state_changed and persist:
state["updated_at"] = now.isoformat()
self._state_repository.save(state)
return result
def _process_bill(
self,
state: dict[str, Any],
bill_id: str,
chat_ids: tuple[str, ...],
outages: list[Outage],
now: datetime,
result: RunResult,
) -> None:
bills = state["bills"]
bill_state = bills.get(bill_id)
if bill_state is None:
bill_state = {"events": {}}
records: dict[str, dict[str, Any]] = bill_state["events"]
current = {
outage.outage_number: outage for outage in outages if outage.stop_datetime() > now
}
for event_id, outage in current.items():
record = records.get(event_id)
if record is None:
if self._notify(chat_ids, new_outage_message(bill_id, outage, now), result):
records[event_id] = _record(outage, now)
result.state_changed = True
continue
previous = Outage.from_dict(record["snapshot"])
if record["status"] == "cancelled":
if self._notify(chat_ids, reactivated_outage_message(bill_id, outage, now), result):
records[event_id] = _record(outage, now)
result.state_changed = True
continue
if outage.fingerprint() != record["notified_hash"]:
if self._notify(
chat_ids,
updated_outage_message(bill_id, previous, outage, now),
result,
):
records[event_id] = _record(outage, now)
result.state_changed = True
continue
if record["missing_checks"]:
record["missing_checks"] = 0
record["last_seen_at"] = now.isoformat()
result.state_changed = True
for event_id, record in list(records.items()):
if event_id in current or record["status"] != "active":
continue
try:
previous = Outage.from_dict(record["snapshot"])
except OutageDataError as error:
result.errors.append(f"Invalid stored outage for bill {bill_id}: {error}")
continue
if previous.start_datetime() <= now:
continue
if record["missing_checks"] == 0:
record["missing_checks"] = 1
result.state_changed = True
elif self._notify(chat_ids, cancelled_outage_message(bill_id, previous, now), result):
record["status"] = "cancelled"
record["missing_checks"] = 2
record["notified_at"] = now.isoformat()
result.state_changed = True
if records and bill_id not in bills:
bills[bill_id] = bill_state
def _notify(self, chat_ids: tuple[str, ...], message: str, result: RunResult) -> bool:
delivered_to_all = True
for chat_id in chat_ids:
try:
self._notifier.send(chat_id, message)
except ExternalServiceError as error:
result.errors.append(str(error))
LOGGER.error("%s", error)
delivered_to_all = False
continue
result.sent += 1
return delivered_to_all
@staticmethod
def _prune_expired(state: dict[str, Any], now: datetime) -> bool:
changed = False
cutoff = now - timedelta(days=30)
for bill_id, bill_state in list(state["bills"].items()):
records = bill_state["events"]
for event_id, record in list(records.items()):
outage = Outage.from_dict(record["snapshot"])
if outage.stop_datetime() < cutoff:
del records[event_id]
changed = True
if not records:
del state["bills"][bill_id]
changed = True
return changed
def _record(outage: Outage, now: datetime) -> dict[str, Any]:
return {
"snapshot": outage.to_dict(),
"notified_hash": outage.fingerprint(),
"status": "active",
"missing_checks": 0,
"last_seen_at": now.isoformat(),
"notified_at": now.isoformat(),
}