Implemented the full clean rewrite for Gitea
All checks were successful
Test blackout notifier / test (push) Successful in 3m41s
Hourly blackout check / check (push) Successful in 8s

This commit is contained in:
Meghdad
2026-07-17 04:33:16 +03:30
parent d7f53ba234
commit 4218e4ac36
30 changed files with 1437 additions and 384 deletions

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from typing import Protocol
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from blackout_notifier.models import Outage, OutageDataError
SAAPA_URL = "https://uiapi2.saapa.ir/api/ebills/PlannedBlackoutsReport"
EITAAYAR_BASE_URL = "https://eitaayar.ir/api"
class ExternalServiceError(RuntimeError):
"""Raised when an external API fails or returns unsafe data."""
class Notifier(Protocol):
def send(self, text: str) -> None: ...
class SaapaClient:
def __init__(
self,
token: str,
*,
timeout: float,
max_events_per_bill: int,
session: requests.Session | None = None,
) -> None:
self._token = token
self._timeout = timeout
self._max_events_per_bill = max_events_per_bill
self._session = session or _retrying_session({"POST"})
def fetch(self, bill_id: str, from_date: str, to_date: str) -> list[Outage]:
try:
response = self._session.post(
SAAPA_URL,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {self._token}",
},
json={"bill_id": bill_id, "from_date": from_date, "to_date": to_date},
timeout=self._timeout,
)
except requests.RequestException as error:
raise ExternalServiceError(
f"SAAPA request failed for bill {bill_id}: {error}"
) from error
if not response.ok:
raise ExternalServiceError(
f"SAAPA returned HTTP {response.status_code} for bill {bill_id}"
)
try:
body = response.json()
except requests.JSONDecodeError as error:
raise ExternalServiceError(f"SAAPA returned invalid JSON for bill {bill_id}") from error
if not isinstance(body, dict) or not isinstance(body.get("data"), list):
raise ExternalServiceError(f"SAAPA response schema is invalid for bill {bill_id}")
raw_events = body["data"]
if len(raw_events) > self._max_events_per_bill:
raise ExternalServiceError(
f"SAAPA returned {len(raw_events)} events for bill {bill_id}; "
f"limit is {self._max_events_per_bill}"
)
events: list[Outage] = []
seen_numbers: set[str] = set()
try:
for raw_event in raw_events:
event = Outage.from_api(raw_event)
if event.outage_number in seen_numbers:
raise OutageDataError(
f"duplicate outage_number {event.outage_number} in one snapshot"
)
seen_numbers.add(event.outage_number)
events.append(event)
except OutageDataError as error:
raise ExternalServiceError(f"Invalid outage for bill {bill_id}: {error}") from error
return events
class EitaaClient:
def __init__(
self,
token: str,
chat_id: str,
*,
timeout: float,
session: requests.Session | None = None,
) -> None:
self._url = f"{EITAAYAR_BASE_URL}/{token}/sendMessage"
self._chat_id = chat_id
self._timeout = timeout
self._session = session or _retrying_session({"GET"})
def send(self, text: str) -> None:
try:
response = self._session.get(
self._url,
params={"chat_id": self._chat_id, "text": text},
timeout=self._timeout,
)
except requests.RequestException as error:
raise ExternalServiceError(f"Eitaa request failed: {error}") from error
if not response.ok:
raise ExternalServiceError(f"Eitaa returned HTTP {response.status_code}")
try:
body = response.json()
except requests.JSONDecodeError as error:
raise ExternalServiceError("Eitaa returned invalid JSON") from error
if not isinstance(body, dict) or body.get("ok") is not True:
description = body.get("description") if isinstance(body, dict) else None
raise ExternalServiceError(
f"Eitaa rejected the message: {description or 'unknown error'}"
)
def _retrying_session(methods: set[str]) -> requests.Session:
retry = Retry(
total=3,
connect=3,
read=3,
status=3,
backoff_factor=0.5,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(methods),
raise_on_status=False,
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
return session