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()