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:
co-authored by
Claude Opus 5.5
parent
e3367c6a2e
commit
22fc8b38bf
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user