Implemented the full clean rewrite for Gitea
This commit is contained in:
11
.env.example
11
.env.example
@@ -1,4 +1,4 @@
|
||||
# API token used to retrieve planned outages
|
||||
# API token used to retrieve planned outages from SAAPA
|
||||
BARGHEMAN_TOKEN=replace-with-bargheman-token
|
||||
|
||||
# Eitaa bot configuration
|
||||
@@ -6,7 +6,10 @@ EITAAYAR_TOKEN=replace-with-eitaayar-token
|
||||
CHAT_ID=replace-with-chat-id
|
||||
|
||||
# One or more comma-separated bill IDs
|
||||
BILL_IDS=7356609804429,761/pe9314604429
|
||||
BILL_IDS=7356609804429,7619314604429
|
||||
|
||||
# JSON file used to remember outages that have already been announced
|
||||
BLACKOUTS_FILE=blackouts.json
|
||||
# Optional settings and their defaults
|
||||
STATE_FILE=state/outages.json
|
||||
LOOKAHEAD_DAYS=5
|
||||
REQUEST_TIMEOUT_SECONDS=15
|
||||
MAX_EVENTS_PER_BILL=25
|
||||
|
||||
48
.gitea/workflows/check.yml
Normal file
48
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Hourly blackout check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "@hourly"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install project
|
||||
run: python -m pip install .
|
||||
|
||||
- name: Check outages and persist state
|
||||
env:
|
||||
BARGHEMAN_TOKEN: ${{ secrets.BARGHEMAN_TOKEN }}
|
||||
EITAAYAR_TOKEN: ${{ secrets.EITAAYAR_TOKEN }}
|
||||
CHAT_ID: ${{ secrets.CHAT_ID }}
|
||||
BILL_IDS: ${{ secrets.BILL_IDS }}
|
||||
STATE_FILE: state/outages.json
|
||||
run: |
|
||||
set +e
|
||||
python -m blackout_notifier check
|
||||
checker_status=$?
|
||||
set -e
|
||||
|
||||
if ! git diff --quiet -- state/outages.json; then
|
||||
git config user.name "gitea-blackout-bot"
|
||||
git config user.email "blackout-bot@mahgit.ir"
|
||||
git add state/outages.json
|
||||
git commit -m "chore(state): update outage notification state [skip ci]"
|
||||
branch="$(git branch --show-current)"
|
||||
git pull --rebase origin "$branch"
|
||||
git push origin "$branch"
|
||||
fi
|
||||
|
||||
exit "$checker_status"
|
||||
32
.gitea/workflows/test.yml
Normal file
32
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Test blackout notifier
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- state/outages.json
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install project
|
||||
run: python -m pip install -e '.[test]'
|
||||
|
||||
- name: Lint
|
||||
run: ruff check .
|
||||
|
||||
- name: Check formatting
|
||||
run: ruff format --check .
|
||||
|
||||
- name: Test
|
||||
run: pytest
|
||||
49
.github/workflows/run-script.yml
vendored
49
.github/workflows/run-script.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: Run Script
|
||||
|
||||
on:
|
||||
# schedule:
|
||||
# - cron: "30 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
run-script:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
|
||||
- name: Run Python Script
|
||||
env:
|
||||
BARGHEMAN_TOKEN: ${{ secrets.BARGHEMAN_TOKEN }}
|
||||
EITAAYAR_TOKEN: ${{ secrets.EITAAYAR_TOKEN }}
|
||||
BLACKOUTS_FILE: "blackouts.json"
|
||||
BILL_IDS: ${{ secrets.BILL_IDS }}
|
||||
CHAT_ID: ${{ secrets.CHAT_ID }}
|
||||
run: |
|
||||
python main.py
|
||||
|
||||
- name: Commit & Push updated data
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions"
|
||||
git config --global user.email "github-actions@github.com"
|
||||
git add blackouts.json
|
||||
git commit -m "Update outage data" || echo "Nothing to commit"
|
||||
git push
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -124,6 +124,7 @@ celerybeat.pid
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
blackouts.json
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
94
README.md
94
README.md
@@ -1,30 +1,94 @@
|
||||
# daily-blackout-check
|
||||
# Daily Blackout Check
|
||||
|
||||
Checks planned power outages and sends new notices to an Eitaa chat.
|
||||
Checks the official SAAPA planned-outage API and sends Persian notifications to
|
||||
an Eitaa chat when an outage is added, changed, cancelled, or restored.
|
||||
|
||||
## Setup
|
||||
The checker is designed for an hourly Gitea Actions workflow. Its deduplication
|
||||
state is stored in `state/outages.json` and committed back to this repository.
|
||||
|
||||
1. Create and activate a Python virtual environment, then install dependencies:
|
||||
## Behavior
|
||||
|
||||
- Queries each configured bill ID from today through five days ahead.
|
||||
- Uses Tehran time regardless of the runner container's timezone.
|
||||
- Sends all upcoming outages when starting with empty state.
|
||||
- Treats an existing outage number with changed time, address, date, reason, or
|
||||
planned status as an update.
|
||||
- Reports a future outage as cancelled only after two consecutive successful
|
||||
checks omit it.
|
||||
- Records a notification only after Eitaa accepts it. This favors delivery over
|
||||
perfect deduplication: a failed state push can cause a duplicate next hour.
|
||||
- Rejects malformed responses and snapshots larger than the configured safety
|
||||
limit without changing that bill's state.
|
||||
|
||||
## Local setup
|
||||
|
||||
Python 3.11 or newer is required. CI uses Python 3.13.
|
||||
|
||||
```sh
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Copy the example configuration and replace its placeholder values:
|
||||
|
||||
```sh
|
||||
python -m pip install -e '.[test]'
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Run the checker:
|
||||
Fill in the required values in `.env`, then preview the next run without sending
|
||||
messages or changing state:
|
||||
|
||||
```sh
|
||||
python main.py
|
||||
python -m blackout_notifier check --dry-run
|
||||
```
|
||||
|
||||
The `.env` file is ignored by Git so tokens are not committed. Environment
|
||||
variables supplied by the shell or GitHub Actions take precedence over `.env`.
|
||||
Run the live checker with:
|
||||
|
||||
`BILL_IDS` accepts one or more comma-separated electricity bill IDs.
|
||||
```sh
|
||||
python -m blackout_notifier check
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Required | Default | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `BARGHEMAN_TOKEN` | Yes | — | SAAPA bearer token |
|
||||
| `EITAAYAR_TOKEN` | Yes | — | EitaaYar bot token |
|
||||
| `CHAT_ID` | Yes | — | Destination Eitaa chat |
|
||||
| `BILL_IDS` | Yes | — | Comma-separated decimal bill IDs |
|
||||
| `STATE_FILE` | No | `state/outages.json` | Durable notification state |
|
||||
| `LOOKAHEAD_DAYS` | No | `5` | Query horizon, from 1 to 30 days |
|
||||
| `REQUEST_TIMEOUT_SECONDS` | No | `15` | HTTP timeout, from 1 to 120 seconds |
|
||||
| `MAX_EVENTS_PER_BILL` | No | `25` | Response safety limit, from 1 to 100 |
|
||||
|
||||
Shell and Gitea-provided variables take precedence over `.env`.
|
||||
|
||||
## Gitea deployment
|
||||
|
||||
This repository targets Gitea 1.25.x and a Docker-based runner advertising the
|
||||
`ubuntu-latest` label. The runner needs outbound access to the Gitea instance,
|
||||
`gitea.com`, PyPI, SAAPA, and EitaaYar.
|
||||
|
||||
1. Enable Actions in the repository settings.
|
||||
2. Confirm an online runner advertises `ubuntu-latest`.
|
||||
3. Add these repository Actions secrets:
|
||||
`BARGHEMAN_TOKEN`, `EITAAYAR_TOKEN`, `CHAT_ID`, and `BILL_IDS`.
|
||||
4. Run the test workflow manually.
|
||||
5. Run **Hourly blackout check** manually once and inspect the notification and
|
||||
resulting state commit.
|
||||
6. Leave the `@hourly` schedule enabled.
|
||||
|
||||
The workflow uses fully qualified `https://gitea.com/actions/...` actions rather
|
||||
than resolving actions through GitHub. Gitea 1.25 ignores workflow `permissions`
|
||||
and `concurrency`, so the notifier workflow deliberately does not rely on them.
|
||||
Its checkout credential must retain the default ability to push to the current
|
||||
repository. Scheduled jobs should not be manually overlapped; in the rare event
|
||||
of a push race, the job fails and unsaved notices may be repeated on the next run.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
ruff check .
|
||||
pytest
|
||||
```
|
||||
|
||||
Tests mock both external services and never send real messages.
|
||||
|
||||
Historical state from the previous implementation is retained at
|
||||
`archive/blackouts-1404.json` for reference only and is not imported.
|
||||
|
||||
20
blockout.py
20
blockout.py
@@ -1,20 +0,0 @@
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def get_main_page() -> str:
|
||||
return requests.get('https://www.qepd.co.ir/').text
|
||||
|
||||
|
||||
def find_blockout_link(html: str) -> None | str:
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
a_tags = soup.find_all('a', href=True)
|
||||
for a_tag in filter(lambda tag: 'اطلاعات برنامه خاموشی ها' in tag.text, a_tags):
|
||||
return a_tag.get('href')
|
||||
return None
|
||||
|
||||
|
||||
page = get_main_page()
|
||||
link = find_blockout_link(page)
|
||||
if not link:
|
||||
raise Exception('unable to find blockout link!')
|
||||
@@ -1,8 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 7356609804429
|
||||
},
|
||||
{
|
||||
"id": 7619314604429
|
||||
}
|
||||
]
|
||||
126
main.py
126
main.py
@@ -1,126 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from jdatetime import datetime, timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load local configuration without overriding variables supplied by the shell or CI.
|
||||
load_dotenv()
|
||||
|
||||
BARGHEMAN_TOKEN = os.getenv("BARGHEMAN_TOKEN", "").strip()
|
||||
EITAAYAR_TOKEN = os.getenv("EITAAYAR_TOKEN", "").strip()
|
||||
CHAT_ID = os.getenv("CHAT_ID", "").strip()
|
||||
BILL_IDS = [bill_id.strip() for bill_id in os.getenv("BILL_IDS", "").split(",") if bill_id.strip()]
|
||||
BLACKOUTS_FILE = os.getenv("BLACKOUTS_FILE", "").strip()
|
||||
|
||||
required_settings = {
|
||||
"BARGHEMAN_TOKEN": BARGHEMAN_TOKEN,
|
||||
"EITAAYAR_TOKEN": EITAAYAR_TOKEN,
|
||||
"CHAT_ID": CHAT_ID,
|
||||
"BILL_IDS": BILL_IDS,
|
||||
"BLACKOUTS_FILE": BLACKOUTS_FILE,
|
||||
}
|
||||
missing_settings = [name for name, value in required_settings.items() if not value]
|
||||
if missing_settings:
|
||||
raise EnvironmentError(f"Missing required environment variables: {', '.join(missing_settings)}")
|
||||
|
||||
today = datetime.now()
|
||||
to_date = today + timedelta(days=5)
|
||||
|
||||
|
||||
def get_blackouts(bill_id) -> list:
|
||||
url = "https://uiapi2.saapa.ir/api/ebills/PlannedBlackoutsReport"
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
"Authorization": f"Bearer {BARGHEMAN_TOKEN}",
|
||||
}
|
||||
payload = {
|
||||
"bill_id": bill_id,
|
||||
"from_date": today.strftime("%Y/%m/%d"),
|
||||
"to_date": to_date.strftime("%Y/%m/%d"),
|
||||
}
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
if response.ok:
|
||||
return response.json().get("data", [])
|
||||
else:
|
||||
print(f"❌ Failed to get blackouts: {response.text}")
|
||||
return []
|
||||
|
||||
|
||||
def load_blackouts() -> dict:
|
||||
if os.path.exists(BLACKOUTS_FILE):
|
||||
with open(BLACKOUTS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def save_blackouts(data) -> None:
|
||||
with open(BLACKOUTS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def format_message(bill_id, entry):
|
||||
RTLM = "\u200f"
|
||||
return f"{RTLM}\n{RTLM}".join([
|
||||
f"⚡️ اطلاعیه خاموشی برق",
|
||||
f"🆔 شناسه قبض: {bill_id}",
|
||||
f"📅 تاریخ: {entry['outage_date']}",
|
||||
f"⏰ ساعت: {entry['outage_start_time']} تا {entry['outage_stop_time']}",
|
||||
f"📍 محل: {entry['outage_address']}",
|
||||
f"📝 دلیل: {entry['reason_outage']}",
|
||||
f"📥 ارسال شده در {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
f".",
|
||||
])
|
||||
|
||||
|
||||
def send_message(text):
|
||||
url = f"https://eitaayar.ir/api/{EITAAYAR_TOKEN}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": CHAT_ID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
response = requests.get(url, params=payload)
|
||||
if not response.ok:
|
||||
print(f"❌ Failed to send message: {response.text}")
|
||||
|
||||
|
||||
def is_entries_valid(entries: list) -> bool:
|
||||
return 0 < len(entries) < 10
|
||||
|
||||
|
||||
def main() -> None:
|
||||
previous_data = load_blackouts()
|
||||
updated_data = previous_data.copy()
|
||||
|
||||
for bill_id in BILL_IDS:
|
||||
new_blackouts = get_blackouts(bill_id)
|
||||
old_blackouts = previous_data.get(bill_id, [])
|
||||
|
||||
new_entries = [entry for entry in new_blackouts if entry not in old_blackouts]
|
||||
|
||||
if not is_entries_valid(new_entries):
|
||||
print('unusual entries was received. count:', len(new_entries))
|
||||
print(json.dumps(new_entries, ensure_ascii=False, indent=2))
|
||||
continue
|
||||
|
||||
for entry in new_entries:
|
||||
message = format_message(bill_id, entry)
|
||||
send_message(message)
|
||||
|
||||
if new_entries:
|
||||
updated_data[bill_id] = old_blackouts + new_entries
|
||||
|
||||
save_blackouts(updated_data)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
"""
|
||||
curl ^"https://uiapi2.saapa.ir/api/ebills/PlannedBlackoutsReport^" ^
|
||||
--compressed ^
|
||||
-X POST ^
|
||||
-H ^"Authorization: Bearer ...^" ^
|
||||
--data-raw ^"{\"bill_id\":\"7619314604429\",\"from_date\":\"1404/03/11\",\"to_date\":\"1404/03/16\"}^"
|
||||
"""
|
||||
59
models.py
59
models.py
@@ -1,59 +0,0 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Model:
|
||||
drive: str = 'json'
|
||||
|
||||
attributes: dict[str, Any] = {}
|
||||
|
||||
def __init__(self, attributes: None | dict[str, Any] = None):
|
||||
if attributes is None:
|
||||
attributes = {}
|
||||
|
||||
for key, value in attributes.items():
|
||||
self.__setattr__(key, value)
|
||||
|
||||
@staticmethod
|
||||
def all() -> list['Model']:
|
||||
model = Model()
|
||||
|
||||
with open(f'database/{model.get_table()}.json', 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
return list(map(lambda attr: Model(attr), data))
|
||||
|
||||
|
||||
def save(self) -> bool:
|
||||
models = self.all()
|
||||
models.append(self)
|
||||
|
||||
data = list(map(lambda model: dict(model), models))
|
||||
with open(f'database/{self.get_table()}.json', 'w+', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return True
|
||||
|
||||
def get_table(self) -> str:
|
||||
return 'bills'
|
||||
|
||||
def __dict__(self):
|
||||
return self.attributes
|
||||
|
||||
def __getattr__(self, item):
|
||||
return self.attributes.get(item)
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
self.attributes[key] = value
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.attributes == other.attributes
|
||||
|
||||
|
||||
|
||||
class Bill(Model):
|
||||
...
|
||||
|
||||
bills = Bill.all()
|
||||
|
||||
print(Bill.all()[0].attributes)
|
||||
38
pyproject.toml
Normal file
38
pyproject.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "daily-blackout-check"
|
||||
version = "1.0.0"
|
||||
description = "Notify an Eitaa chat about planned electricity outages"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"jdatetime>=5,<7",
|
||||
"python-dotenv>=1,<2",
|
||||
"requests>=2.32,<3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8,<10",
|
||||
"ruff>=0.12,<1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
blackout-notifier = "blackout_notifier.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-q"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
@@ -1,4 +0,0 @@
|
||||
requests
|
||||
jdatetime
|
||||
beautifulsoup4
|
||||
python-dotenv
|
||||
@@ -1,94 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
token = os.getenv("EITAAYAR_TOKEN")
|
||||
chat_id = os.getenv("CHAT_ID")
|
||||
|
||||
if not token:
|
||||
raise EnvironmentError("EITAAYAR_TOKEN is not set!")
|
||||
|
||||
if not chat_id:
|
||||
raise EnvironmentError("CHAT_ID is not set!")
|
||||
|
||||
|
||||
def send_message(text: str) -> dict:
|
||||
sent_at: int = int(time.time())
|
||||
url = f'https://eitaayar.ir/api/{token}/sendMessage?chat_id={chat_id}&text={text}&title={sent_at=}'
|
||||
return requests.get(url).json()
|
||||
|
||||
|
||||
def get_blockout_page() -> str:
|
||||
blockout_link = 'https://qepd.co.ir/fa-IR/DouranPortal/6423'
|
||||
return requests.get(blockout_link).text
|
||||
|
||||
|
||||
def convert_to_english_numbers(text):
|
||||
persian_to_english = str.maketrans("۰۱۲۳۴۵۶۷۸۹", "0123456789")
|
||||
return text.translate(persian_to_english)
|
||||
|
||||
|
||||
def find_times(text: str) -> list[str]:
|
||||
text = convert_to_english_numbers(text)
|
||||
pattern = r"(\d{1,2}:\d{2}|\d{1,2})"
|
||||
return re.findall(pattern, text)
|
||||
|
||||
def is_time_past(time_str: str) -> bool:
|
||||
try:
|
||||
current_time = datetime.now(iran_tz).time()
|
||||
|
||||
if ":" in time_str:
|
||||
time_obj = datetime.strptime(time_str.strip(), "%H:%M").time()
|
||||
else:
|
||||
time_obj = datetime.strptime(time_str.strip(), "%H").time()
|
||||
|
||||
return time_obj < current_time
|
||||
except Exception:
|
||||
return false
|
||||
|
||||
def make_message(html: str) -> str|None:
|
||||
return None
|
||||
if len(html) == 0:
|
||||
return "خطا در خواندن صفحه!"
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
# table = soup.find('table', id='ctl01_ctl00_myDataList')
|
||||
|
||||
block_needle = 'بلوک A4'
|
||||
try:
|
||||
paragraphs = soup.find_all('p')
|
||||
block = soup.find('p', string=lambda text: isinstance(text, str) and block_needle in text)
|
||||
time_index = paragraphs.index(block) - 1
|
||||
text = paragraphs[time_index].get_text(separator='\n', strip=True)
|
||||
start, end, *_ = find_times(text)
|
||||
|
||||
if is_time_past(start):
|
||||
return None
|
||||
|
||||
return text
|
||||
except:
|
||||
return f"اطلاعات {block_needle} یافت نشد!"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
page = get_blockout_page()
|
||||
message = make_message(page)
|
||||
except requests.exceptions.Timeout:
|
||||
message = 'درخواست ارسال نشد!'
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
response = send_message(message)
|
||||
if not response.get('ok'):
|
||||
if response.get('description'):
|
||||
raise Exception(response.get('description'))
|
||||
else:
|
||||
raise Exception('something sent wrong!')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
3
src/blackout_notifier/__init__.py
Normal file
3
src/blackout_notifier/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Planned electricity outage notifier."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
4
src/blackout_notifier/__main__.py
Normal file
4
src/blackout_notifier/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from blackout_notifier.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
68
src/blackout_notifier/cli.py
Normal file
68
src/blackout_notifier/cli.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from blackout_notifier.clients import EitaaClient, SaapaClient
|
||||
from blackout_notifier.config import Config, ConfigurationError
|
||||
from blackout_notifier.models import tehran_now
|
||||
from blackout_notifier.service import OutageService
|
||||
from blackout_notifier.state import StateError, StateRepository
|
||||
|
||||
|
||||
class ConsoleNotifier:
|
||||
def send(self, text: str) -> None:
|
||||
print("\n--- notification preview ---")
|
||||
print(text)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Check and announce planned power outages")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
check_parser = subparsers.add_parser("check", help="fetch and process planned outages")
|
||||
check_parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="print notifications without sending them or changing state",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = build_parser().parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
|
||||
try:
|
||||
config = Config.from_env()
|
||||
state_repository = StateRepository(config.state_file)
|
||||
notifier = (
|
||||
ConsoleNotifier()
|
||||
if arguments.dry_run
|
||||
else EitaaClient(
|
||||
config.eitaayar_token,
|
||||
config.chat_id,
|
||||
timeout=config.request_timeout_seconds,
|
||||
)
|
||||
)
|
||||
service = OutageService(
|
||||
bill_ids=config.bill_ids,
|
||||
lookahead_days=config.lookahead_days,
|
||||
saapa=SaapaClient(
|
||||
config.bargheman_token,
|
||||
timeout=config.request_timeout_seconds,
|
||||
max_events_per_bill=config.max_events_per_bill,
|
||||
),
|
||||
notifier=notifier,
|
||||
state_repository=state_repository,
|
||||
)
|
||||
result = service.run(tehran_now(), persist=not arguments.dry_run)
|
||||
except (ConfigurationError, StateError) as error:
|
||||
print(f"Configuration/state error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(
|
||||
f"Check complete: sent={result.sent}, state_changed={result.state_changed}, "
|
||||
f"errors={len(result.errors)}"
|
||||
)
|
||||
return 1 if result.errors else 0
|
||||
136
src/blackout_notifier/clients.py
Normal file
136
src/blackout_notifier/clients.py
Normal 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
|
||||
85
src/blackout_notifier/config.py
Normal file
85
src/blackout_notifier/config.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
class ConfigurationError(ValueError):
|
||||
"""Raised when application configuration is missing or invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Config:
|
||||
bargheman_token: str
|
||||
eitaayar_token: str
|
||||
chat_id: str
|
||||
bill_ids: tuple[str, ...]
|
||||
state_file: Path
|
||||
lookahead_days: int = 5
|
||||
request_timeout_seconds: float = 15.0
|
||||
max_events_per_bill: int = 25
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Config:
|
||||
load_dotenv(override=False)
|
||||
|
||||
values = {
|
||||
"BARGHEMAN_TOKEN": os.getenv("BARGHEMAN_TOKEN", "").strip(),
|
||||
"EITAAYAR_TOKEN": os.getenv("EITAAYAR_TOKEN", "").strip(),
|
||||
"CHAT_ID": os.getenv("CHAT_ID", "").strip(),
|
||||
}
|
||||
missing = [name for name, value in values.items() if not value]
|
||||
|
||||
raw_bill_ids = os.getenv("BILL_IDS", "")
|
||||
bill_ids = tuple(
|
||||
dict.fromkeys(part.strip() for part in raw_bill_ids.split(",") if part.strip())
|
||||
)
|
||||
if not bill_ids:
|
||||
missing.append("BILL_IDS")
|
||||
elif invalid := [bill_id for bill_id in bill_ids if not bill_id.isdecimal()]:
|
||||
raise ConfigurationError(
|
||||
f"BILL_IDS must contain only decimal IDs: {', '.join(invalid)}"
|
||||
)
|
||||
|
||||
if missing:
|
||||
raise ConfigurationError(
|
||||
f"Missing required environment variables: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
return cls(
|
||||
bargheman_token=values["BARGHEMAN_TOKEN"],
|
||||
eitaayar_token=values["EITAAYAR_TOKEN"],
|
||||
chat_id=values["CHAT_ID"],
|
||||
bill_ids=bill_ids,
|
||||
state_file=Path(os.getenv("STATE_FILE", "state/outages.json").strip()),
|
||||
lookahead_days=_integer_setting("LOOKAHEAD_DAYS", 5, minimum=1, maximum=30),
|
||||
request_timeout_seconds=_float_setting(
|
||||
"REQUEST_TIMEOUT_SECONDS", 15.0, minimum=1.0, maximum=120.0
|
||||
),
|
||||
max_events_per_bill=_integer_setting("MAX_EVENTS_PER_BILL", 25, minimum=1, maximum=100),
|
||||
)
|
||||
|
||||
|
||||
def _integer_setting(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||
raw_value = os.getenv(name, str(default)).strip()
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except ValueError as error:
|
||||
raise ConfigurationError(f"{name} must be an integer") from error
|
||||
if not minimum <= value <= maximum:
|
||||
raise ConfigurationError(f"{name} must be between {minimum} and {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _float_setting(name: str, default: float, *, minimum: float, maximum: float) -> float:
|
||||
raw_value = os.getenv(name, str(default)).strip()
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except ValueError as error:
|
||||
raise ConfigurationError(f"{name} must be a number") from error
|
||||
if not minimum <= value <= maximum:
|
||||
raise ConfigurationError(f"{name} must be between {minimum:g} and {maximum:g}")
|
||||
return value
|
||||
85
src/blackout_notifier/messages.py
Normal file
85
src/blackout_notifier/messages.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import jdatetime
|
||||
|
||||
from blackout_notifier.models import TEHRAN, Outage
|
||||
|
||||
RTL = "\u200f"
|
||||
|
||||
|
||||
def new_outage_message(bill_id: str, outage: Outage, now: datetime) -> str:
|
||||
return _join(
|
||||
"⚡ اطلاعیه خاموشی جدید",
|
||||
*_outage_lines(bill_id, outage),
|
||||
_sent_at(now),
|
||||
)
|
||||
|
||||
|
||||
def updated_outage_message(bill_id: str, previous: Outage, current: Outage, now: datetime) -> str:
|
||||
labels = {
|
||||
"outage_date": "تاریخ",
|
||||
"start_time": "ساعت شروع",
|
||||
"stop_time": "ساعت پایان",
|
||||
"address": "محل",
|
||||
"reason": "دلیل",
|
||||
"is_planned": "وضعیت برنامهریزی",
|
||||
}
|
||||
changes = []
|
||||
for field, label in labels.items():
|
||||
old_value = _display_value(getattr(previous, field))
|
||||
new_value = _display_value(getattr(current, field))
|
||||
if old_value != new_value:
|
||||
changes.append(f"✏️ {label}: {old_value} ← {new_value}")
|
||||
return _join(
|
||||
"🔄 تغییر در برنامه خاموشی",
|
||||
f"🆔 شناسه قبض: {bill_id}",
|
||||
f"🔖 شماره خاموشی: {current.outage_number}",
|
||||
*changes,
|
||||
_sent_at(now),
|
||||
)
|
||||
|
||||
|
||||
def cancelled_outage_message(bill_id: str, outage: Outage, now: datetime) -> str:
|
||||
return _join(
|
||||
"✅ لغو احتمالی برنامه خاموشی",
|
||||
"این خاموشی در دو بررسی پیاپی مشاهده نشد.",
|
||||
*_outage_lines(bill_id, outage),
|
||||
_sent_at(now),
|
||||
)
|
||||
|
||||
|
||||
def reactivated_outage_message(bill_id: str, outage: Outage, now: datetime) -> str:
|
||||
return _join(
|
||||
"⚠️ بازگشت برنامه خاموشی",
|
||||
*_outage_lines(bill_id, outage),
|
||||
_sent_at(now),
|
||||
)
|
||||
|
||||
|
||||
def _outage_lines(bill_id: str, outage: Outage) -> tuple[str, ...]:
|
||||
return (
|
||||
f"🆔 شناسه قبض: {bill_id}",
|
||||
f"🔖 شماره خاموشی: {outage.outage_number}",
|
||||
f"📅 تاریخ: {outage.outage_date}",
|
||||
f"⏰ ساعت: {outage.start_time} تا {outage.stop_time}",
|
||||
f"📍 محل: {outage.address or 'نامشخص'}",
|
||||
f"📝 دلیل: {outage.reason or 'اعلام نشده'}",
|
||||
)
|
||||
|
||||
|
||||
def _sent_at(now: datetime) -> str:
|
||||
local_now = now.astimezone(TEHRAN)
|
||||
jalali_now = jdatetime.datetime.fromgregorian(datetime=local_now.replace(tzinfo=None))
|
||||
return f"📥 ارسال در {jalali_now.strftime('%Y/%m/%d %H:%M:%S')}"
|
||||
|
||||
|
||||
def _display_value(value: str | bool) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "برنامهریزیشده" if value else "برنامهریزینشده"
|
||||
return value or "نامشخص"
|
||||
|
||||
|
||||
def _join(*lines: str) -> str:
|
||||
return f"{RTL}\n{RTL}".join(lines)
|
||||
125
src/blackout_notifier/models.py
Normal file
125
src/blackout_notifier/models.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import jdatetime
|
||||
|
||||
TEHRAN = ZoneInfo("Asia/Tehran")
|
||||
|
||||
|
||||
class OutageDataError(ValueError):
|
||||
"""Raised when an outage payload cannot be normalized safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Outage:
|
||||
outage_number: str
|
||||
outage_date: str
|
||||
start_time: str
|
||||
stop_time: str
|
||||
address: str
|
||||
reason: str
|
||||
is_planned: bool
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, payload: Any) -> Outage:
|
||||
if not isinstance(payload, dict):
|
||||
raise OutageDataError("outage entry must be an object")
|
||||
|
||||
outage_number = _required_text(payload, "outage_number")
|
||||
outage_date = _required_text(payload, "outage_date")
|
||||
start_time = _required_text(payload, "outage_start_time")
|
||||
stop_time = _required_text(payload, "outage_stop_time")
|
||||
address = str(payload.get("outage_address") or payload.get("address") or "").strip()
|
||||
reason = str(payload.get("reason_outage") or "").strip()
|
||||
is_planned = payload.get("is_planned", True)
|
||||
if not isinstance(is_planned, bool):
|
||||
raise OutageDataError("is_planned must be a boolean")
|
||||
|
||||
outage = cls(
|
||||
outage_number=outage_number,
|
||||
outage_date=outage_date,
|
||||
start_time=start_time,
|
||||
stop_time=stop_time,
|
||||
address=address,
|
||||
reason=reason,
|
||||
is_planned=is_planned,
|
||||
)
|
||||
outage.start_datetime()
|
||||
outage.stop_datetime()
|
||||
return outage
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Any) -> Outage:
|
||||
if not isinstance(payload, dict):
|
||||
raise OutageDataError("stored outage snapshot must be an object")
|
||||
try:
|
||||
outage = cls(
|
||||
outage_number=str(payload["outage_number"]),
|
||||
outage_date=str(payload["outage_date"]),
|
||||
start_time=str(payload["start_time"]),
|
||||
stop_time=str(payload["stop_time"]),
|
||||
address=str(payload["address"]),
|
||||
reason=str(payload["reason"]),
|
||||
is_planned=payload["is_planned"],
|
||||
)
|
||||
except (KeyError, TypeError) as error:
|
||||
raise OutageDataError("stored outage snapshot has an invalid schema") from error
|
||||
if not isinstance(outage.is_planned, bool):
|
||||
raise OutageDataError("stored is_planned must be a boolean")
|
||||
outage.start_datetime()
|
||||
outage.stop_datetime()
|
||||
return outage
|
||||
|
||||
def to_dict(self) -> dict[str, str | bool]:
|
||||
return asdict(self)
|
||||
|
||||
def fingerprint(self) -> str:
|
||||
serialized = json.dumps(
|
||||
self.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
|
||||
def start_datetime(self) -> datetime:
|
||||
return _jalali_datetime(self.outage_date, self.start_time)
|
||||
|
||||
def stop_datetime(self) -> datetime:
|
||||
start = self.start_datetime()
|
||||
stop = _jalali_datetime(self.outage_date, self.stop_time)
|
||||
if stop <= start:
|
||||
stop += timedelta(days=1)
|
||||
return stop
|
||||
|
||||
|
||||
def jalali_query_window(now: datetime, lookahead_days: int) -> tuple[str, str]:
|
||||
local_now = now.astimezone(TEHRAN)
|
||||
start = jdatetime.date.fromgregorian(date=local_now.date())
|
||||
end = jdatetime.date.fromgregorian(date=local_now.date() + timedelta(days=lookahead_days))
|
||||
return start.strftime("%Y/%m/%d"), end.strftime("%Y/%m/%d")
|
||||
|
||||
|
||||
def tehran_now() -> datetime:
|
||||
return datetime.now(TEHRAN)
|
||||
|
||||
|
||||
def _required_text(payload: dict[str, Any], field: str) -> str:
|
||||
value = payload.get(field)
|
||||
if value is None or not str(value).strip():
|
||||
raise OutageDataError(f"{field} is required")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _jalali_datetime(date_text: str, time_text: str) -> datetime:
|
||||
try:
|
||||
year, month, day = (int(part) for part in date_text.split("/"))
|
||||
hour, minute = (int(part) for part in time_text.split(":"))
|
||||
gregorian_date: date = jdatetime.date(year, month, day).togregorian()
|
||||
parsed_time = time(hour, minute)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise OutageDataError(f"invalid Jalali date/time: {date_text} {time_text}") from error
|
||||
return datetime.combine(gregorian_date, parsed_time, tzinfo=TEHRAN)
|
||||
165
src/blackout_notifier/service.py
Normal file
165
src/blackout_notifier/service.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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_ids: tuple[str, ...],
|
||||
lookahead_days: int,
|
||||
saapa: SaapaClient,
|
||||
notifier: Notifier,
|
||||
state_repository: StateRepository,
|
||||
) -> None:
|
||||
self._bill_ids = bill_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 in self._bill_ids:
|
||||
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, 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,
|
||||
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(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(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(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(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, message: str, result: RunResult) -> bool:
|
||||
try:
|
||||
self._notifier.send(message)
|
||||
except ExternalServiceError as error:
|
||||
result.errors.append(str(error))
|
||||
LOGGER.error("%s", error)
|
||||
return False
|
||||
result.sent += 1
|
||||
return True
|
||||
|
||||
@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(),
|
||||
}
|
||||
102
src/blackout_notifier/state.py
Normal file
102
src/blackout_notifier/state.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from blackout_notifier.models import Outage, OutageDataError
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class StateError(RuntimeError):
|
||||
"""Raised when persisted state is unreadable or unsafe to overwrite."""
|
||||
|
||||
|
||||
def empty_state() -> dict[str, Any]:
|
||||
return {"schema_version": SCHEMA_VERSION, "updated_at": None, "bills": {}}
|
||||
|
||||
|
||||
class StateRepository:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
|
||||
def load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return empty_state()
|
||||
try:
|
||||
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise StateError(f"Cannot read state file {self.path}: {error}") from error
|
||||
self._validate(payload)
|
||||
return payload
|
||||
|
||||
def save(self, state: dict[str, Any]) -> None:
|
||||
self._validate(state)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
dir=self.path.parent,
|
||||
prefix=f".{self.path.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as temporary_file:
|
||||
temporary_path = Path(temporary_file.name)
|
||||
json.dump(state, temporary_file, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
temporary_file.write("\n")
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
os.replace(temporary_path, self.path)
|
||||
except OSError as error:
|
||||
raise StateError(f"Cannot save state file {self.path}: {error}") from error
|
||||
finally:
|
||||
if temporary_path and temporary_path.exists():
|
||||
temporary_path.unlink()
|
||||
|
||||
@staticmethod
|
||||
def _validate(state: Any) -> None:
|
||||
if not isinstance(state, dict) or state.get("schema_version") != SCHEMA_VERSION:
|
||||
raise StateError(f"State must use schema_version {SCHEMA_VERSION}")
|
||||
if state.get("updated_at") is not None and not isinstance(state.get("updated_at"), str):
|
||||
raise StateError("State updated_at must be a string or null")
|
||||
bills = state.get("bills")
|
||||
if not isinstance(bills, dict):
|
||||
raise StateError("State bills must be an object")
|
||||
for bill_id, bill_state in bills.items():
|
||||
if not isinstance(bill_id, str) or not isinstance(bill_state, dict):
|
||||
raise StateError("Invalid bill state")
|
||||
events = bill_state.get("events")
|
||||
if not isinstance(events, dict):
|
||||
raise StateError(f"State events for bill {bill_id} must be an object")
|
||||
for event_id, record in events.items():
|
||||
_validate_record(bill_id, event_id, record)
|
||||
|
||||
|
||||
def _validate_record(bill_id: str, event_id: Any, record: Any) -> None:
|
||||
if not isinstance(event_id, str) or not isinstance(record, dict):
|
||||
raise StateError(f"Invalid event record for bill {bill_id}")
|
||||
try:
|
||||
outage = Outage.from_dict(record.get("snapshot"))
|
||||
except OutageDataError as error:
|
||||
raise StateError(
|
||||
f"Invalid snapshot for bill {bill_id}, event {event_id}: {error}"
|
||||
) from error
|
||||
if outage.outage_number != event_id:
|
||||
raise StateError(f"Event key does not match outage_number for bill {bill_id}")
|
||||
if record.get("status") not in {"active", "cancelled"}:
|
||||
raise StateError(f"Invalid event status for bill {bill_id}, event {event_id}")
|
||||
missing_checks = record.get("missing_checks")
|
||||
if (
|
||||
not isinstance(missing_checks, int)
|
||||
or isinstance(missing_checks, bool)
|
||||
or missing_checks < 0
|
||||
):
|
||||
raise StateError(f"Invalid missing_checks for bill {bill_id}, event {event_id}")
|
||||
for name in ("notified_hash", "last_seen_at", "notified_at"):
|
||||
if not isinstance(record.get(name), str):
|
||||
raise StateError(f"Invalid {name} for bill {bill_id}, event {event_id}")
|
||||
26
state/outages.json
Normal file
26
state/outages.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"bills": {
|
||||
"7356609804429": {
|
||||
"events": {
|
||||
"158818": {
|
||||
"last_seen_at": "2026-07-17T04:32:29.526959+03:30",
|
||||
"missing_checks": 0,
|
||||
"notified_at": "2026-07-17T04:32:29.526959+03:30",
|
||||
"notified_hash": "66abd8a5158281f5954a081a48a4d7986d34ad6bb0f257a9dd0629d00f2d0f49",
|
||||
"snapshot": {
|
||||
"address": "باغ قلعه 8متری سوم ج ترانسفورماتور",
|
||||
"is_planned": false,
|
||||
"outage_date": "1405/04/27",
|
||||
"outage_number": "158818",
|
||||
"reason": "مدیریت اضطراری بار",
|
||||
"start_time": "18:00",
|
||||
"stop_time": "20:00"
|
||||
},
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema_version": 1,
|
||||
"updated_at": "2026-07-17T04:32:29.526959+03:30"
|
||||
}
|
||||
25
tests/conftest.py
Normal file
25
tests/conftest.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.models import TEHRAN, Outage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def now() -> datetime:
|
||||
return datetime(2026, 7, 17, 8, 0, tzinfo=TEHRAN)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outage() -> Outage:
|
||||
return Outage(
|
||||
outage_number="404992999001",
|
||||
outage_date="1405/04/27",
|
||||
start_time="09:00",
|
||||
stop_time="11:00",
|
||||
address="فیدر آزمایشی",
|
||||
reason="مدیریت بار",
|
||||
is_planned=True,
|
||||
)
|
||||
80
tests/test_clients.py
Normal file
80
tests/test_clients.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.clients import EitaaClient, ExternalServiceError, SaapaClient
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, body: Any, *, status_code: int = 200) -> None:
|
||||
self._body = body
|
||||
self.status_code = status_code
|
||||
self.ok = 200 <= status_code < 400
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._body
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, response: FakeResponse) -> None:
|
||||
self.response = response
|
||||
self.calls: list[tuple[str, str, dict[str, Any]]] = []
|
||||
|
||||
def post(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||
self.calls.append(("POST", url, kwargs))
|
||||
return self.response
|
||||
|
||||
def get(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||
self.calls.append(("GET", url, kwargs))
|
||||
return self.response
|
||||
|
||||
|
||||
def _payload(number: int = 1) -> dict[str, Any]:
|
||||
return {
|
||||
"outage_number": number,
|
||||
"outage_date": "1405/04/27",
|
||||
"outage_start_time": "09:00",
|
||||
"outage_stop_time": "11:00",
|
||||
"outage_address": "آدرس",
|
||||
"reason_outage": "مدیریت بار",
|
||||
"is_planned": True,
|
||||
}
|
||||
|
||||
|
||||
def test_saapa_client_sends_expected_request() -> None:
|
||||
session = FakeSession(FakeResponse({"data": [_payload()]}))
|
||||
client = SaapaClient("secret", timeout=7, max_events_per_bill=25, session=session) # type: ignore[arg-type]
|
||||
|
||||
events = client.fetch("123", "1405/04/26", "1405/04/31")
|
||||
|
||||
assert len(events) == 1
|
||||
_, _, kwargs = session.calls[0]
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer secret"
|
||||
assert kwargs["json"]["bill_id"] == "123"
|
||||
assert kwargs["timeout"] == 7
|
||||
|
||||
|
||||
def test_saapa_rejects_oversized_or_duplicate_snapshot() -> None:
|
||||
oversized = FakeSession(FakeResponse({"data": [_payload(1), _payload(2)]}))
|
||||
client = SaapaClient("token", timeout=1, max_events_per_bill=1, session=oversized) # type: ignore[arg-type]
|
||||
with pytest.raises(ExternalServiceError, match="limit is 1"):
|
||||
client.fetch("123", "from", "to")
|
||||
|
||||
duplicate = FakeSession(FakeResponse({"data": [_payload(1), _payload(1)]}))
|
||||
client = SaapaClient("token", timeout=1, max_events_per_bill=5, session=duplicate) # type: ignore[arg-type]
|
||||
with pytest.raises(ExternalServiceError, match="duplicate outage_number"):
|
||||
client.fetch("123", "from", "to")
|
||||
|
||||
|
||||
def test_eitaa_requires_ok_response() -> None:
|
||||
session = FakeSession(FakeResponse({"ok": False, "description": "denied"}))
|
||||
client = EitaaClient("token", "chat", timeout=2, session=session) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ExternalServiceError, match="denied"):
|
||||
client.send("message")
|
||||
|
||||
_, url, kwargs = session.calls[0]
|
||||
assert url.endswith("/token/sendMessage")
|
||||
assert kwargs["params"] == {"chat_id": "chat", "text": "message"}
|
||||
55
tests/test_config.py
Normal file
55
tests/test_config.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.config import Config, ConfigurationError
|
||||
|
||||
REQUIRED = {
|
||||
"BARGHEMAN_TOKEN": "bargheman",
|
||||
"EITAAYAR_TOKEN": "eitaayar",
|
||||
"CHAT_ID": "chat",
|
||||
"BILL_IDS": " 123,456,123 ,, ",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def do_not_load_project_dotenv(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("blackout_notifier.config.load_dotenv", lambda **_: None)
|
||||
|
||||
|
||||
def test_config_parses_and_deduplicates_bill_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in REQUIRED.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
config = Config.from_env()
|
||||
|
||||
assert config.bill_ids == ("123", "456")
|
||||
assert str(config.state_file) == "state/outages.json"
|
||||
assert config.lookahead_days == 5
|
||||
|
||||
|
||||
def test_config_reports_all_missing_required_values(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in REQUIRED:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
expected = "BARGHEMAN_TOKEN.*EITAAYAR_TOKEN.*CHAT_ID.*BILL_IDS"
|
||||
with pytest.raises(ConfigurationError, match=expected):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_config_rejects_non_decimal_bill_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in REQUIRED.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
monkeypatch.setenv("BILL_IDS", "123,761/pe9314604429")
|
||||
|
||||
with pytest.raises(ConfigurationError, match="decimal IDs"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_config_validates_numeric_ranges(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in REQUIRED.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
monkeypatch.setenv("LOOKAHEAD_DAYS", "0")
|
||||
|
||||
with pytest.raises(ConfigurationError, match="between 1 and 30"):
|
||||
Config.from_env()
|
||||
57
tests/test_models.py
Normal file
57
tests/test_models.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.models import TEHRAN, Outage, OutageDataError, jalali_query_window
|
||||
|
||||
|
||||
def test_jalali_query_window_uses_tehran_date() -> None:
|
||||
utc_near_midnight = datetime.fromisoformat("2026-07-16T20:31:00+00:00")
|
||||
|
||||
assert jalali_query_window(utc_near_midnight, 5) == ("1405/04/26", "1405/04/31")
|
||||
|
||||
|
||||
def test_outage_parses_api_payload() -> None:
|
||||
outage = Outage.from_api(
|
||||
{
|
||||
"outage_number": 404992999001,
|
||||
"outage_date": "1405/04/27",
|
||||
"outage_start_time": "09:00",
|
||||
"outage_stop_time": "11:00",
|
||||
"outage_address": " فیدر آزمایشی ",
|
||||
"reason_outage": "مدیریت بار",
|
||||
"is_planned": True,
|
||||
}
|
||||
)
|
||||
|
||||
assert outage.outage_number == "404992999001"
|
||||
assert outage.address == "فیدر آزمایشی"
|
||||
assert outage.start_datetime() == datetime(2026, 7, 18, 9, 0, tzinfo=TEHRAN)
|
||||
|
||||
|
||||
def test_outage_supports_cross_midnight_window() -> None:
|
||||
outage = Outage(
|
||||
outage_number="1",
|
||||
outage_date="1405/04/27",
|
||||
start_time="23:00",
|
||||
stop_time="01:00",
|
||||
address="",
|
||||
reason="",
|
||||
is_planned=True,
|
||||
)
|
||||
|
||||
assert outage.stop_datetime() - outage.start_datetime() == timedelta(hours=2)
|
||||
|
||||
|
||||
def test_outage_rejects_invalid_date() -> None:
|
||||
with pytest.raises(OutageDataError, match="invalid Jalali"):
|
||||
Outage.from_api(
|
||||
{
|
||||
"outage_number": "1",
|
||||
"outage_date": "not-a-date",
|
||||
"outage_start_time": "09:00",
|
||||
"outage_stop_time": "11:00",
|
||||
}
|
||||
)
|
||||
183
tests/test_service.py
Normal file
183
tests/test_service.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from blackout_notifier.clients import ExternalServiceError
|
||||
from blackout_notifier.models import Outage
|
||||
from blackout_notifier.service import OutageService
|
||||
from blackout_notifier.state import StateRepository
|
||||
|
||||
|
||||
class FakeSaapa:
|
||||
def __init__(self, snapshots: Mapping[str, list[Outage] | Exception]) -> None:
|
||||
self.snapshots = dict(snapshots)
|
||||
self.calls: list[tuple[str, str, str]] = []
|
||||
|
||||
def fetch(self, bill_id: str, from_date: str, to_date: str) -> list[Outage]:
|
||||
self.calls.append((bill_id, from_date, to_date))
|
||||
result = self.snapshots[bill_id]
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
|
||||
class FakeNotifier:
|
||||
def __init__(self, *, fail: bool = False, fail_on_call: int | None = None) -> None:
|
||||
self.messages: list[str] = []
|
||||
self.fail = fail
|
||||
self.fail_on_call = fail_on_call
|
||||
self.calls = 0
|
||||
|
||||
def send(self, text: str) -> None:
|
||||
self.calls += 1
|
||||
if self.fail or self.calls == self.fail_on_call:
|
||||
raise ExternalServiceError("Eitaa unavailable")
|
||||
self.messages.append(text)
|
||||
|
||||
|
||||
def _service(
|
||||
path: Path,
|
||||
saapa: FakeSaapa,
|
||||
notifier: FakeNotifier,
|
||||
bill_ids: tuple[str, ...] = ("123",),
|
||||
) -> OutageService:
|
||||
return OutageService(
|
||||
bill_ids=bill_ids,
|
||||
lookahead_days=5,
|
||||
saapa=saapa, # type: ignore[arg-type]
|
||||
notifier=notifier,
|
||||
state_repository=StateRepository(path),
|
||||
)
|
||||
|
||||
|
||||
def test_first_run_sends_upcoming_once(tmp_path: Path, now: datetime, outage: Outage) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
saapa = FakeSaapa({"123": [outage]})
|
||||
notifier = FakeNotifier()
|
||||
service = _service(path, saapa, notifier)
|
||||
|
||||
first = service.run(now)
|
||||
first_state = path.read_text(encoding="utf-8")
|
||||
second = service.run(now)
|
||||
|
||||
assert first.sent == 1
|
||||
assert first.state_changed is True
|
||||
assert "خاموشی جدید" in notifier.messages[0]
|
||||
assert second.sent == 0
|
||||
assert second.state_changed is False
|
||||
assert path.read_text(encoding="utf-8") == first_state
|
||||
|
||||
|
||||
def test_changed_outage_sends_field_diff(tmp_path: Path, now: datetime, outage: Outage) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
notifier = FakeNotifier()
|
||||
saapa = FakeSaapa({"123": [outage]})
|
||||
service = _service(path, saapa, notifier)
|
||||
service.run(now)
|
||||
|
||||
saapa.snapshots["123"] = [replace(outage, start_time="10:00")]
|
||||
result = service.run(now)
|
||||
|
||||
assert result.sent == 1
|
||||
assert "تغییر در برنامه" in notifier.messages[-1]
|
||||
assert "09:00" in notifier.messages[-1]
|
||||
assert "10:00" in notifier.messages[-1]
|
||||
|
||||
|
||||
def test_cancellation_requires_two_successful_omissions_and_can_reactivate(
|
||||
tmp_path: Path, now: datetime, outage: Outage
|
||||
) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
notifier = FakeNotifier()
|
||||
saapa = FakeSaapa({"123": [outage]})
|
||||
service = _service(path, saapa, notifier)
|
||||
service.run(now)
|
||||
notifier.messages.clear()
|
||||
|
||||
saapa.snapshots["123"] = []
|
||||
first_missing = service.run(now)
|
||||
second_missing = service.run(now)
|
||||
|
||||
assert first_missing.sent == 0
|
||||
assert second_missing.sent == 1
|
||||
assert "دو بررسی پیاپی" in notifier.messages[-1]
|
||||
|
||||
saapa.snapshots["123"] = [outage]
|
||||
restored = service.run(now)
|
||||
assert restored.sent == 1
|
||||
assert "بازگشت برنامه" in notifier.messages[-1]
|
||||
|
||||
|
||||
def test_failed_notification_does_not_advance_state(
|
||||
tmp_path: Path, now: datetime, outage: Outage
|
||||
) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
service = _service(path, FakeSaapa({"123": [outage]}), FakeNotifier(fail=True))
|
||||
|
||||
result = service.run(now)
|
||||
|
||||
assert result.sent == 0
|
||||
assert result.errors == ["Eitaa unavailable"]
|
||||
assert result.state_changed is False
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_fetch_failure_for_one_bill_does_not_block_another(
|
||||
tmp_path: Path, now: datetime, outage: Outage
|
||||
) -> None:
|
||||
saapa = FakeSaapa({"123": ExternalServiceError("SAAPA failed for 123"), "456": [outage]})
|
||||
notifier = FakeNotifier()
|
||||
service = _service(tmp_path / "state.json", saapa, notifier, ("123", "456"))
|
||||
|
||||
result = service.run(now)
|
||||
|
||||
assert result.sent == 1
|
||||
assert result.errors == ["SAAPA failed for 123"]
|
||||
assert len(notifier.messages) == 1
|
||||
|
||||
|
||||
def test_partial_delivery_persists_only_successful_events(
|
||||
tmp_path: Path, now: datetime, outage: Outage
|
||||
) -> None:
|
||||
second_outage = replace(outage, outage_number="404992999002", start_time="12:00")
|
||||
path = tmp_path / "state.json"
|
||||
notifier = FakeNotifier(fail_on_call=2)
|
||||
service = _service(path, FakeSaapa({"123": [outage, second_outage]}), notifier)
|
||||
|
||||
result = service.run(now)
|
||||
records = StateRepository(path).load()["bills"]["123"]["events"]
|
||||
|
||||
assert result.sent == 1
|
||||
assert result.errors == ["Eitaa unavailable"]
|
||||
assert set(records) == {outage.outage_number}
|
||||
|
||||
|
||||
def test_failed_fetch_never_advances_cancellation_counter(
|
||||
tmp_path: Path, now: datetime, outage: Outage
|
||||
) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
saapa = FakeSaapa({"123": [outage]})
|
||||
service = _service(path, saapa, FakeNotifier())
|
||||
service.run(now)
|
||||
original_state = path.read_text(encoding="utf-8")
|
||||
|
||||
saapa.snapshots["123"] = ExternalServiceError("SAAPA unavailable")
|
||||
result = service.run(now)
|
||||
|
||||
assert result.errors == ["SAAPA unavailable"]
|
||||
assert result.state_changed is False
|
||||
assert path.read_text(encoding="utf-8") == original_state
|
||||
|
||||
|
||||
def test_dry_run_never_writes_state(tmp_path: Path, now: datetime, outage: Outage) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
service = _service(path, FakeSaapa({"123": [outage]}), FakeNotifier())
|
||||
|
||||
result = service.run(now, persist=False)
|
||||
|
||||
assert result.sent == 1
|
||||
assert result.state_changed is True
|
||||
assert not path.exists()
|
||||
28
tests/test_state.py
Normal file
28
tests/test_state.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.state import StateError, StateRepository, empty_state
|
||||
|
||||
|
||||
def test_state_round_trip_is_deterministic(tmp_path: Path) -> None:
|
||||
repository = StateRepository(tmp_path / "nested" / "outages.json")
|
||||
|
||||
repository.save(empty_state())
|
||||
first = repository.path.read_text(encoding="utf-8")
|
||||
repository.save(repository.load())
|
||||
|
||||
assert repository.load() == empty_state()
|
||||
assert repository.path.read_text(encoding="utf-8") == first
|
||||
|
||||
|
||||
def test_corrupt_state_fails_closed(tmp_path: Path) -> None:
|
||||
path = tmp_path / "outages.json"
|
||||
path.write_text("not json", encoding="utf-8")
|
||||
|
||||
with pytest.raises(StateError, match="Cannot read state"):
|
||||
StateRepository(path).load()
|
||||
|
||||
assert path.read_text(encoding="utf-8") == "not json"
|
||||
Reference in New Issue
Block a user