Files
EO_Sevice/main.py
T
Anry DasandClaude Opus 5.5 22fc8b38bf Load config, harden processing and notifications
- Actually load configs/config.yaml at startup (init_config was commented out)
- Add timeouts and error handling to all HTTP calls; run blocking requests
  in threads; serialize processing with a lock; run initial check in background
- URL-encode Telegram messages and HTML-escape scraped data
- Fix GPV crash on empty schedule, 00:00 end time, date sorting and fallback format
- Parse PO table per row; fix daily reminder check and change detection
- Use per-instance timestamps in dataclasses; robust config value parsing
- Matrix: use v3 PUT send endpoint; strip secrets from logged errors
- Resolve templates/static/test paths relative to the project; use lifespan
- Move debug GPV data to test/data_gpv.json; add CLAUDE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:04:18 +03:00

234 lines
6.5 KiB
Python

import asyncio
import logging
import os
from contextlib import asynccontextmanager, suppress
from datetime import datetime, UTC
from typing import Optional, List
import uvicorn
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, Request
from starlette.responses import FileResponse, HTMLResponse
from starlette.routing import Mount
from starlette.templating import Jinja2Templates
import app_config as cfg
from model import JobData
from utils import process_gpv, process_po, get_parsed_po_data, get_parsed_gpv_data, send_message_to_tg_get, \
send_matrix_notification
DESCRIPTION = '''
Energy Outage Inform Service
It provides:
* getting state/value from some configured sensors
* own health endpoint
Created by -=:dAs:=-
'''
########################
cfg.init_config()
SCHEDULER: Optional[AsyncIOScheduler] = None
LOG = cfg.LOG
TEMPLATES = Jinja2Templates(directory=cfg.TEMPLATES_DIR)
# prevents simultaneous processing (scheduled job and /update) and duplicated notifications
PROCESS_LOCK = asyncio.Lock()
INITIAL_CHECK_TASK: Optional[asyncio.Task] = None
########################
async def check_and_notify():
async with PROCESS_LOCK:
for process in (process_gpv, process_po):
try:
await process()
except Exception:
LOG.exception(f'Unexpected error in {process.__name__}')
@asynccontextmanager
async def lifespan(_: FastAPI):
global SCHEDULER, INITIAL_CHECK_TASK
# --- STARTUP LOGIC ---
LOG.info("--- Starting EO Notifier...")
SCHEDULER = AsyncIOScheduler(job_defaults={'coalesce': True, 'max_instances': 1, 'misfire_grace_time': 60})
mins = cfg.PROCESSING_MINUTES if not cfg.IS_DEBUG else list(range(0, 60, 2))
for h in range(cfg.PROCESS_START_HOUR, cfg.PROCESS_STOP_HOUR):
for m in mins:
SCHEDULER.add_job(
check_and_notify,
'cron',
hour=h,
minute=m,
id=f'check_EO_at_{h}h_{m}m'
)
LOG.info(f"Scheduler: job to check at {h}:{m}")
SCHEDULER.start()
LOG.info("Scheduler started")
# Run initial check in background, so service starts even if data sources are unavailable
INITIAL_CHECK_TASK = asyncio.create_task(check_and_notify())
yield
# --- SHUTDOWN LOGIC ---
if INITIAL_CHECK_TASK and not INITIAL_CHECK_TASK.done():
INITIAL_CHECK_TASK.cancel()
with suppress(asyncio.CancelledError):
await INITIAL_CHECK_TASK
if SCHEDULER and SCHEDULER.running:
SCHEDULER.shutdown(wait=False)
LOG.info('Scheduler stopped')
LOG.info('--- EO Notifier shutting down')
TAGS_METADATA = []
app = FastAPI(
title="Energy Outage Inform Service by -=:dAs:=-",
description=DESCRIPTION,
version=cfg.APP_VERSION,
license_info={
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
},
openapi_tags=TAGS_METADATA,
lifespan=lifespan
)
api = FastAPI()
app.mount("/api/v1", api)
########################
def is_scheduler_running() -> bool:
return bool(SCHEDULER and SCHEDULER.running)
@app.get("/favicon.ico", include_in_schema=False)
async def get_favicon():
return FileResponse(os.path.join(cfg.STATIC_DIR, 'favicon.png'))
def list_endpoints():
url_list = [r for r in api.routes if 'HEAD' not in (getattr(r, 'methods', None) or {'HEAD'})]
return url_list
def get_path_prefix():
for r in app.routes:
if isinstance(r, Mount):
return r.path
return None
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
help_messages = {
"Service": "Energy Outage Inform Service by -=:dAs:=",
"version": f"{cfg.APP_VERSION}",
"status": "running",
"Scheduler running": is_scheduler_running(),
"timestamp": date_format(datetime.now(UTC))
}
return TEMPLATES.TemplateResponse(request, "info.html", {
"help_messages": help_messages,
"title": "Energy Outage Inform Service by -=:dAs:=",
"EPs": list_endpoints(),
"path_prefix": get_path_prefix(),
"PO_events": get_parsed_po_data(),
"GPV_events": get_parsed_gpv_data()
})
@api.get("/status",
summary='Show status of the service',
description='Showing current status of the service')
async def get_status():
"""Detailed health check"""
return {
"status": "healthy",
"config_loaded": bool(cfg.CONFIG),
"PO_data": get_parsed_po_data(),
"GPV_data": get_parsed_gpv_data(),
"scheduler_running": is_scheduler_running(),
"scheduler_jobs": await get_scheduled_jobs()
}
@api.get("/update",
summary='Updates data from source',
description='Updates data from web site immediately')
async def update():
await check_and_notify()
status = await get_status()
return status
async def get_scheduled_jobs() -> List[JobData]:
if not SCHEDULER:
return []
return [JobData(name=j.id, trigger=str(j.trigger), next=j.next_run_time) for j in SCHEDULER.get_jobs()]
@api.get("/health",
summary='Health of the service',
description='Showing current health of the service. Returns OK if it launched')
async def health_check():
return {
"status": "OK"
}
@api.get("/test_message/{messanger}",
summary='Send message to messanger',
description='Send test message to messanger according to current configuration')
async def send_test_message(messanger: str):
messanger_up = messanger.upper()
msg = f'Test message to {messanger_up}'
if messanger_up == "TG":
is_sent = await asyncio.to_thread(send_message_to_tg_get, msg) == 200
elif messanger_up == "MX":
is_sent = await asyncio.to_thread(send_matrix_notification, msg)
else:
return {
"status": f"Error: Wrong messanger '{messanger_up}'"
}
if not is_sent:
return {
"status": f"Error: message to '{messanger_up}' was not sent"
}
return {
"status": "OK"
}
@api.get('/logs',
summary='Show LOGs',
description='Showing Service''s LOGs if it configured')
async def get_logs():
return {'logs': list(cfg.LOG_LIST)}
def date_format(dt: datetime, fmt: str = '%Y-%m-%d %H:%M:%S'):
return dt.strftime(fmt)
def main():
logging.basicConfig(
level=logging.INFO,
format=cfg.LOG_FORMAT
)
cfg.LOG.info('Application starting')
uvicorn.run("main:app", **cfg.get_uvicorn_config())
if __name__ == '__main__':
main()