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>
This commit is contained in:
Anry Das
2026-10-13 14:04:18 +03:00
co-authored by Claude Opus 5.5
parent e3367c6a2e
commit 22fc8b38bf
8 changed files with 683 additions and 444 deletions
+73 -54
View File
@@ -1,16 +1,19 @@
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, BackgroundTasks, Request
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 POData, JobData
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
@@ -26,38 +29,33 @@ Created by -=:dAs:=-
'''
########################
# cfg.init_config() ToDo: TEST IT!
cfg.init_config()
SCHEDULER: Optional[AsyncIOScheduler] = None
LOG = cfg.LOG
TEMPLATES = Jinja2Templates(directory="templates")
########################
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
)
api = FastAPI()
app.mount("/api/v1", api)
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():
await process_gpv()
await process_po()
async with PROCESS_LOCK:
for process in (process_gpv, process_po):
try:
await process()
except Exception:
LOG.exception(f'Unexpected error in {process.__name__}')
@app.on_event("startup")
async def startup_event():
global SCHEDULER
@asynccontextmanager
async def lifespan(_: FastAPI):
global SCHEDULER, INITIAL_CHECK_TASK
# --- STARTUP LOGIC ---
LOG.info("--- Starting EO Notifier...")
SCHEDULER = AsyncIOScheduler()
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):
mins = cfg.PROCESSING_MINUTES if not cfg.IS_DEBUG else [x for x in range(0, 60) if x % 2 == 0]
for m in mins:
SCHEDULER.add_job(
check_and_notify,
@@ -71,27 +69,54 @@ async def startup_event():
SCHEDULER.start()
LOG.info("Scheduler started")
# Run initial check
await check_and_notify()
# Run initial check in background, so service starts even if data sources are unavailable
INITIAL_CHECK_TASK = asyncio.create_task(check_and_notify())
yield
@app.on_event("shutdown")
async def shutdown_event():
# --- SHUTDOWN LOGIC ---
if SCHEDULER:
SCHEDULER.shutdown()
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('static/favicon.png')
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 r.methods]
url_list = [r for r in api.routes if 'HEAD' not in (getattr(r, 'methods', None) or {'HEAD'})]
return url_list
@@ -108,12 +133,11 @@ async def root(request: Request):
"Service": "Energy Outage Inform Service by -=:dAs:=",
"version": f"{cfg.APP_VERSION}",
"status": "running",
"Scheduler running": SCHEDULER.running,
"Scheduler running": is_scheduler_running(),
"timestamp": date_format(datetime.now(UTC))
}
return TEMPLATES.TemplateResponse("info.html", {
"request": request,
return TEMPLATES.TemplateResponse(request, "info.html", {
"help_messages": help_messages,
"title": "Energy Outage Inform Service by -=:dAs:=",
"EPs": list_endpoints(),
@@ -128,13 +152,12 @@ async def root(request: Request):
description='Showing current status of the service')
async def get_status():
"""Detailed health check"""
global SCHEDULER
return {
"status": "healthy",
"config_loaded": bool(cfg.CONFIG),
"PO_data": get_parsed_po_data(),
"GPV_data": get_parsed_gpv_data(),
"scheduler_running": SCHEDULER.running,
"scheduler_running": is_scheduler_running(),
"scheduler_jobs": await get_scheduled_jobs()
}
@@ -149,17 +172,9 @@ async def update():
async def get_scheduled_jobs() -> List[JobData]:
global SCHEDULER
jobs: List[JobData] = []
for j in SCHEDULER.get_jobs():
jobs.append(
JobData(
name=j.id,
trigger=str(j.trigger),
next=j.next_run_time)
)
return jobs
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",
@@ -174,17 +189,21 @@ async def health_check():
@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 = None):
async def send_test_message(messanger: str):
messanger_up = messanger.upper()
msg = f'Test message to {messanger_up}'
if messanger_up == "TG":
send_message_to_tg_get(msg)
is_sent = await asyncio.to_thread(send_message_to_tg_get, msg) == 200
elif messanger_up == "MX":
send_matrix_notification(msg)
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"
}
@@ -194,7 +213,7 @@ async def send_test_message(messanger: str = None):
summary='Show LOGs',
description='Showing Service''s LOGs if it configured')
async def get_logs():
return {'logs': cfg.LOG_LIST}
return {'logs': list(cfg.LOG_LIST)}
def date_format(dt: datetime, fmt: str = '%Y-%m-%d %H:%M:%S'):
@@ -204,7 +223,7 @@ def date_format(dt: datetime, fmt: str = '%Y-%m-%d %H:%M:%S'):
def main():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] - %(name)s: %(funcName)s[%(lineno)d] - %(message)s'
format=cfg.LOG_FORMAT
)
cfg.LOG.info('Application starting')
uvicorn.run("main:app", **cfg.get_uvicorn_config())