175 lines
6.2 KiB
Python
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(),
|
|
}
|