- 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>
234 lines
6.5 KiB
Python
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()
|