Initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/configs/secrets.yaml
|
||||||
137
app_config.py
Normal file
137
app_config.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from config_file import read_yaml
|
||||||
|
|
||||||
|
APP_VERSION="1.0"
|
||||||
|
SCRIPT_PATH = os.path.dirname(__file__)
|
||||||
|
CONFIGS_DIR = SCRIPT_PATH + "/configs"
|
||||||
|
CONFIG_FILE_NAME = CONFIGS_DIR + "/config.yaml"
|
||||||
|
SECRETS_NAME = 'secrets.yaml'
|
||||||
|
CONFIG: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
API_KEY = ''
|
||||||
|
IN_MEMORY_LOGS = False
|
||||||
|
IN_MEMORY_LOGS_LEN = 100
|
||||||
|
HOST = '0.0.0.0'
|
||||||
|
PORT: int = 8000
|
||||||
|
IS_DEBUG = False
|
||||||
|
TG_TOKEN = ""
|
||||||
|
TG_CHAT = ""
|
||||||
|
USE_MATRIX = False
|
||||||
|
MX_SERVER = ""
|
||||||
|
MX_ROOM = ""
|
||||||
|
MX_TOKEN = ""
|
||||||
|
ACCOUNT = ""
|
||||||
|
SETTLEMENT = ""
|
||||||
|
STREET = ""
|
||||||
|
HOUSE = ""
|
||||||
|
BUILDING_PART_NUMBER = ""
|
||||||
|
APARTMENT = ""
|
||||||
|
PROCESS_START_HOUR = 6
|
||||||
|
PROCESS_STOP_HOUR = 23
|
||||||
|
PROCESSING_MINUTES: List[int] = [5, 15, 30, 45, 55]
|
||||||
|
|
||||||
|
def read_app_config():
|
||||||
|
global CONFIG
|
||||||
|
if not CONFIG:
|
||||||
|
config_path = Path(CONFIG_FILE_NAME)
|
||||||
|
if not config_path.exists():
|
||||||
|
LOG.error(f"{CONFIG_FILE_NAME} not found!")
|
||||||
|
raise FileNotFoundError(f"{CONFIG_FILE_NAME} not found")
|
||||||
|
|
||||||
|
CONFIG = read_yaml(CONFIG_FILE_NAME, SECRETS_NAME)
|
||||||
|
|
||||||
|
def get_config_value(cfg, section, key, default):
|
||||||
|
return cfg[section][key] if section in cfg else default
|
||||||
|
|
||||||
|
def parse_config():
|
||||||
|
global API_KEY, IN_MEMORY_LOGS, IN_MEMORY_LOGS_LEN, HOST, PORT, IS_DEBUG, \
|
||||||
|
TG_TOKEN, TG_CHAT, \
|
||||||
|
USE_MATRIX, MX_SERVER, MX_ROOM, MX_TOKEN, \
|
||||||
|
ACCOUNT, SETTLEMENT, STREET, HOUSE, BUILDING_PART_NUMBER, APARTMENT, \
|
||||||
|
PROCESS_START_HOUR, PROCESS_STOP_HOUR
|
||||||
|
HOST = CONFIG.get('application', {}).get('host', '')
|
||||||
|
if not HOST:
|
||||||
|
HOST = '0.0.0.0'
|
||||||
|
PORT = CONFIG.get('application', {}).get('port', 8000)
|
||||||
|
API_KEY = CONFIG.get('application').get('api-key', '')
|
||||||
|
IN_MEMORY_LOGS = str(CONFIG.get('application', {}).get('logs_ep', {}).get('enabled', False)).lower() == 'true'
|
||||||
|
IN_MEMORY_LOGS_LEN = CONFIG.get('application', {}).get('logs_ep', {}).get('max_records', 100)
|
||||||
|
IS_DEBUG = CONFIG.get('application').get('debug', False) == 'true'
|
||||||
|
TG_TOKEN = CONFIG.get('notifications', {}).get('tg', {}).get('token', '')
|
||||||
|
TG_CHAT = CONFIG.get('notifications', {}).get('tg', {}).get('chat_id', '')
|
||||||
|
USE_MATRIX = str(CONFIG.get('notifications', {}).get('matrix', {}).get('use_matrix', False)).lower() == 'true'
|
||||||
|
MX_SERVER = CONFIG.get('notifications', {}).get('matrix', {}).get('mx_server', '')
|
||||||
|
MX_ROOM = CONFIG.get('notifications', {}).get('matrix', {}).get('mx_room_id', '')
|
||||||
|
MX_TOKEN = CONFIG.get('notifications', {}).get('matrix', {}).get('mx_access_token', '')
|
||||||
|
ACCOUNT = CONFIG.get('data', {}).get('account', {})
|
||||||
|
SETTLEMENT = CONFIG.get('data', {}).get('settlement', {})
|
||||||
|
STREET = CONFIG.get('data', {}).get('street', {})
|
||||||
|
HOUSE = CONFIG.get('data', {}).get('house', {})
|
||||||
|
BUILDING_PART_NUMBER = CONFIG.get('data', {}).get('building_part_number', {})
|
||||||
|
APARTMENT = CONFIG.get('data', {}).get('apartment', {})
|
||||||
|
PROCESS_START_HOUR = CONFIG.get('application').get('process', {}).get('start_hour', 6)
|
||||||
|
PROCESS_STOP_HOUR = CONFIG.get('application').get('process', {}).get('stop_hour', 23)
|
||||||
|
if PROCESS_START_HOUR > PROCESS_STOP_HOUR:
|
||||||
|
PROCESS_STOP_HOUR = 23
|
||||||
|
|
||||||
|
|
||||||
|
def get_uvicorn_config():
|
||||||
|
# global HOST, PORT
|
||||||
|
if HOST and PORT:
|
||||||
|
cfg = {
|
||||||
|
"host": f"{HOST}",
|
||||||
|
"port": PORT,
|
||||||
|
"access_log": True,
|
||||||
|
"log_level": "info",
|
||||||
|
"log_config": f"{CONFIGS_DIR}/log.yaml"
|
||||||
|
# "docs_url": "/api-docs"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
cfg = {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8000,
|
||||||
|
"reload": True,
|
||||||
|
"access_log": False,
|
||||||
|
"log_level": "info",
|
||||||
|
"log_config": f"{CONFIGS_DIR}/log.yaml"
|
||||||
|
# "docs_url": "/api-docs"
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.debug(f'got uvicorn_config: {cfg}')
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def log_config(): # Not used due to not working
|
||||||
|
cfg = uvicorn.config.LOGGING_CONFIG
|
||||||
|
cfg["formatters"]["access"]["fmt"] = (
|
||||||
|
'%(asctime)s [%(levelname)s] - %(name)s: %(funcName)s[%(lineno)d] - %(message)s'
|
||||||
|
)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
LOG_LIST = []
|
||||||
|
|
||||||
|
class ListHandler(logging.Handler):
|
||||||
|
def emit(self, record):
|
||||||
|
if IN_MEMORY_LOGS:
|
||||||
|
LOG_LIST.append(self.format(record))
|
||||||
|
while len(LOG_LIST) > IN_MEMORY_LOGS_LEN:
|
||||||
|
LOG_LIST.pop(0)
|
||||||
|
###
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
handler = ListHandler()
|
||||||
|
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] - %(name)s: %(funcName)s[%(lineno)d] - %(message)s'))
|
||||||
|
LOG.addHandler(handler)
|
||||||
|
|
||||||
|
def init_config():
|
||||||
|
read_app_config()
|
||||||
|
parse_config()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_config()
|
||||||
74
config_file.py
Normal file
74
config_file.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
def read_config(name):
|
||||||
|
if not os.path.isfile(name):
|
||||||
|
raise Exception(f"File {name} doesn't exists")
|
||||||
|
filename, ext = os.path.splitext(name)
|
||||||
|
if 'json' in ext:
|
||||||
|
return read_json(name)
|
||||||
|
elif 'properties' in ext:
|
||||||
|
return read_prop(name)
|
||||||
|
elif 'yaml' in ext or 'yml' in ext:
|
||||||
|
return read_yaml(name)
|
||||||
|
else:
|
||||||
|
raise Exception("Wrong file type")
|
||||||
|
|
||||||
|
def read_json(name):
|
||||||
|
with open(name, 'r', encoding='utf-8') as f:
|
||||||
|
j_conf = json.load(f)
|
||||||
|
conf = {}
|
||||||
|
for key, value in j_conf.items():
|
||||||
|
conf[key] = value
|
||||||
|
return conf
|
||||||
|
|
||||||
|
def read_prop(filepath, sep='=', comment_char='#'):
|
||||||
|
"""
|
||||||
|
Read the file passed as parameter as a properties file.
|
||||||
|
"""
|
||||||
|
conf = {}
|
||||||
|
with open(filepath, "rt") as f:
|
||||||
|
for line in f:
|
||||||
|
l = line.strip()
|
||||||
|
if l and not l.startswith(comment_char):
|
||||||
|
key_value = l.split(sep)
|
||||||
|
key = key_value[0].strip()
|
||||||
|
value = sep.join(key_value[1:]).strip().strip('"')
|
||||||
|
conf[key] = value
|
||||||
|
return conf
|
||||||
|
|
||||||
|
def read_yaml(name, secrets_file='secrets.yaml'):
|
||||||
|
# Load secrets first
|
||||||
|
secrets = {}
|
||||||
|
secrets_path = os.path.join(os.path.dirname(name), secrets_file)
|
||||||
|
|
||||||
|
if os.path.exists(secrets_path):
|
||||||
|
with open(secrets_path, 'r', encoding='utf-8') as f:
|
||||||
|
secrets = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
# Define a custom constructor for !secret tag
|
||||||
|
def secret_constructor(loader, node):
|
||||||
|
secret_key = loader.construct_scalar(node)
|
||||||
|
if secret_key not in secrets:
|
||||||
|
raise ValueError(f"Secret '{secret_key}' not found in {secrets_file}")
|
||||||
|
return secrets[secret_key]
|
||||||
|
|
||||||
|
# Register the custom constructor
|
||||||
|
yaml.add_constructor('!secret', secret_constructor, Loader=yaml.SafeLoader)
|
||||||
|
|
||||||
|
# Load the main configuration
|
||||||
|
conf = {}
|
||||||
|
with open(name, 'r', encoding='utf-8') as f:
|
||||||
|
y_conf = yaml.safe_load(f)
|
||||||
|
if y_conf:
|
||||||
|
for key, value in y_conf.items():
|
||||||
|
conf[key] = value
|
||||||
|
|
||||||
|
return conf
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
31
configs/config.yaml
Normal file
31
configs/config.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
application:
|
||||||
|
host: ""
|
||||||
|
port: 25156
|
||||||
|
api-key: ""
|
||||||
|
debug: "true"
|
||||||
|
process:
|
||||||
|
start_hour: 6
|
||||||
|
stop_hour: 23
|
||||||
|
logs_ep:
|
||||||
|
enabled: "true"
|
||||||
|
max_records: 100
|
||||||
|
|
||||||
|
notifications:
|
||||||
|
tg:
|
||||||
|
token: !secret token
|
||||||
|
chat_id: !secret chat_id
|
||||||
|
matrix:
|
||||||
|
use_matrix: "true"
|
||||||
|
mx_server: !secret mx_server
|
||||||
|
mx_room_id: !secret mx_room_id
|
||||||
|
mx_access_token: !secret mx_access_token
|
||||||
|
|
||||||
|
data:
|
||||||
|
account: !secret account
|
||||||
|
settlement: !secret settlement
|
||||||
|
street: !secret street
|
||||||
|
house: !secret house
|
||||||
|
building_part_number: ""
|
||||||
|
apartment: !secret apartment
|
||||||
|
|
||||||
|
mentions: !secret mentions
|
||||||
34
configs/log.yaml
Normal file
34
configs/log.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
version: 1
|
||||||
|
disable_existing_loggers: False
|
||||||
|
formatters:
|
||||||
|
default:
|
||||||
|
# "()": uvicorn.logging.DefaultFormatter
|
||||||
|
format: '%(asctime)s [%(levelname)s] - %(name)s: %(funcName)s[%(lineno)d] - %(message)s'
|
||||||
|
access:
|
||||||
|
# "()": uvicorn.logging.AccessFormatter
|
||||||
|
format: '%(asctime)s [%(levelname)s] - %(name)s: %(funcName)s[%(lineno)d] - %(message)s'
|
||||||
|
handlers:
|
||||||
|
default:
|
||||||
|
formatter: default
|
||||||
|
class: logging.StreamHandler
|
||||||
|
stream: ext://sys.stderr
|
||||||
|
access:
|
||||||
|
formatter: access
|
||||||
|
class: logging.StreamHandler
|
||||||
|
stream: ext://sys.stdout
|
||||||
|
loggers:
|
||||||
|
uvicorn.error:
|
||||||
|
level: INFO
|
||||||
|
handlers:
|
||||||
|
- default
|
||||||
|
propagate: no
|
||||||
|
uvicorn.access:
|
||||||
|
level: INFO
|
||||||
|
handlers:
|
||||||
|
- access
|
||||||
|
propagate: no
|
||||||
|
root:
|
||||||
|
level: DEBUG
|
||||||
|
handlers:
|
||||||
|
- default
|
||||||
|
propagate: no
|
||||||
214
main.py
Normal file
214
main.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import logging
|
||||||
|
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 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 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() ToDo: TEST IT!
|
||||||
|
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)
|
||||||
|
########################
|
||||||
|
async def check_and_notify():
|
||||||
|
await process_gpv()
|
||||||
|
await process_po()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
global SCHEDULER
|
||||||
|
# --- STARTUP LOGIC ---
|
||||||
|
LOG.info("--- Starting EO Notifier...")
|
||||||
|
SCHEDULER = AsyncIOScheduler()
|
||||||
|
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,
|
||||||
|
'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
|
||||||
|
await check_and_notify()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event():
|
||||||
|
# --- SHUTDOWN LOGIC ---
|
||||||
|
if SCHEDULER:
|
||||||
|
SCHEDULER.shutdown()
|
||||||
|
LOG.info('Scheduler stopped')
|
||||||
|
|
||||||
|
LOG.info('--- EO Notifier shutting down')
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
async def get_favicon():
|
||||||
|
return FileResponse('static/favicon.png')
|
||||||
|
|
||||||
|
|
||||||
|
def list_endpoints():
|
||||||
|
url_list = [r for r in api.routes if 'HEAD' not in r.methods]
|
||||||
|
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": SCHEDULER.running,
|
||||||
|
"timestamp": date_format(datetime.now(UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
return TEMPLATES.TemplateResponse("info.html", {
|
||||||
|
"request": request,
|
||||||
|
"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"""
|
||||||
|
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_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]:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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 = None):
|
||||||
|
messanger_up = messanger.upper()
|
||||||
|
msg = f'Test message to {messanger_up}'
|
||||||
|
if messanger_up == "TG":
|
||||||
|
send_message_to_tg_get(msg)
|
||||||
|
elif messanger_up == "MX":
|
||||||
|
send_matrix_notification(msg)
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": f"Error: Wrong messanger '{messanger_up}'"
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api.get('/logs',
|
||||||
|
summary='Show LOGs',
|
||||||
|
description='Showing Service''s LOGs if it configured')
|
||||||
|
async def get_logs():
|
||||||
|
return {'logs': 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='%(asctime)s [%(levelname)s] - %(name)s: %(funcName)s[%(lineno)d] - %(message)s'
|
||||||
|
)
|
||||||
|
cfg.LOG.info('Application starting')
|
||||||
|
uvicorn.run("main:app", **cfg.get_uvicorn_config())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
73
model.py
Normal file
73
model.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Self
|
||||||
|
import app_config as cfg
|
||||||
|
|
||||||
|
LOG = cfg.LOG
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class POData:
|
||||||
|
city: str = field(repr=False)
|
||||||
|
street: str = field(repr=False)
|
||||||
|
house: int = field(repr=False)
|
||||||
|
po_type: str = field(repr=False)
|
||||||
|
reason: str = field(repr=False)
|
||||||
|
placement_date: str
|
||||||
|
start_date: str
|
||||||
|
stop_date: str
|
||||||
|
start_time: str
|
||||||
|
stop_time: str
|
||||||
|
notified_at: datetime = field(repr=False, default=None)
|
||||||
|
updated_at: datetime = field(repr=False, default=datetime.now())
|
||||||
|
need_to_send: bool = field(repr=False, default=True)
|
||||||
|
start_ts: float = field(init=False)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.start_date and self.start_time:
|
||||||
|
self.start_ts = datetime.strptime(f'{self.start_date} {self.start_time}', '%Y.%m.%d %H:%M').timestamp()
|
||||||
|
else:
|
||||||
|
LOG.error(f'wrong start_date or start_time in {self}')
|
||||||
|
|
||||||
|
def set_sent(self):
|
||||||
|
self.need_to_send = False
|
||||||
|
|
||||||
|
def set_notified_now(self):
|
||||||
|
self.notified_at = datetime.now()
|
||||||
|
|
||||||
|
def update_notified_at(self, old_data: Self):
|
||||||
|
self.notified_at = old_data.notified_at
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GPVData:
|
||||||
|
approved_from: str
|
||||||
|
event_date: str
|
||||||
|
hours_off: List
|
||||||
|
hours_on: List
|
||||||
|
outage_queue: str = field(repr=False)
|
||||||
|
notified_at: datetime = field(repr=False, default=None)
|
||||||
|
updated_at: datetime = field(repr=False, default=datetime.now())
|
||||||
|
need_to_send: bool = field(repr=False, default=True)
|
||||||
|
start_ts: List[float] = field(default_factory=list, init=False)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.event_date and self.hours_off and self.hours_on:
|
||||||
|
for index, value in enumerate(self.hours_on):
|
||||||
|
self.start_ts.append(datetime.strptime(f'{self.event_date} {self.hours_on[index]}', '%d.%m.%Y %H:%M').timestamp())
|
||||||
|
else:
|
||||||
|
LOG.error(f'wrong start_date or start_time in {self}')
|
||||||
|
|
||||||
|
def set_send(self):
|
||||||
|
self.need_to_send = False
|
||||||
|
self.notified_at = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JobData:
|
||||||
|
name: str
|
||||||
|
trigger: str
|
||||||
|
next: str
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
pass
|
||||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
pyyaml~=6.0.3
|
||||||
|
fastapi~=0.115.12
|
||||||
|
fastapi[standard]
|
||||||
|
requests~=2.33.0
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
apscheduler==3.10.4
|
||||||
|
lxml~=5.3.1
|
||||||
|
jinja2==3.1.6
|
||||||
|
|
||||||
|
starlette~=0.46.2
|
||||||
|
urllib3~=2.6.3
|
||||||
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
46
templates/info.html
Normal file
46
templates/info.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
<hr style="width: 40%; margin-left: 0;">
|
||||||
|
<h3>Info</h3>
|
||||||
|
<table><tr><th colspan="2"></th></tr>
|
||||||
|
{% for k,v in help_messages.items() %}
|
||||||
|
<tr>
|
||||||
|
<td align="right"><b>{{ k }}:</b></td><td>{{ v }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<hr style="width: 40%; margin-left: 0;">
|
||||||
|
<h3>API EPs</h3>
|
||||||
|
{% for e in EPs %}
|
||||||
|
<a href="{{ path_prefix }}{{ e.path }}">{{ path_prefix }}{{ e.path }}</a><br/>
|
||||||
|
{% endfor %}
|
||||||
|
<hr style="width: 40%; margin-left: 0;">
|
||||||
|
{{ info | safe }}
|
||||||
|
<h3>Pover Outages:</h3>
|
||||||
|
{% if PO_events %}
|
||||||
|
{% for e in PO_events %}
|
||||||
|
{{ e }}<br/>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p style="color: green; font-weight: bold;">No Pover Outages planned</p>
|
||||||
|
{% endif %}
|
||||||
|
<hr style="width: 40%; margin-left: 0;">
|
||||||
|
<h3>GPVs:</h3>
|
||||||
|
{% if GPV_events %}
|
||||||
|
{% for e in GPV_events %}
|
||||||
|
{{ e }}<br/>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p style="color: green; font-weight: bold;">No GPVs planned</p>
|
||||||
|
{% endif %}
|
||||||
|
<hr style="width: 40%; margin-left: 0;">
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
149
test/data_po_2days_01.html
Normal file
149
test/data_po_2days_01.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html><body>
|
||||||
|
<div class='shutdowns-container'>
|
||||||
|
<a class='back' onclick='history.back()'>Повернутися на попередню сторінку</a>
|
||||||
|
<div class='flex-container'>
|
||||||
|
<div class='info-line'>
|
||||||
|
<h2>Вимкнення за вашою адресою станом на 04.04.2026 10:03</h2>
|
||||||
|
<div class='subtitle'>
|
||||||
|
<div class='address'>Надвірна, вул. Грушевського, буд. 9 кв. 20</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='no-shutdown-button'>
|
||||||
|
<a class="button orange button_help" href="/uk/no_power_on">Подати заявку про відсутність світла</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id='shutdowns-table'>
|
||||||
|
<div class='table'>
|
||||||
|
<div class='flex table-header'>
|
||||||
|
<div class='region'>Район</div>
|
||||||
|
<div class='city'>Населений пункт</div>
|
||||||
|
<div class='street'>Вулиця</div>
|
||||||
|
<div class='house_number'>№ буд</div>
|
||||||
|
<div class='building_part_number'>Корпус</div>
|
||||||
|
<div class='dispatch_name'>Диспетчерська назва об'єкту</div>
|
||||||
|
<div class='type-shutdown'>Вид робіт</div>
|
||||||
|
<div class='reason-shutdown'>Причина робіт</div>
|
||||||
|
<div class='placement_date'>Дата розміщення</div>
|
||||||
|
<div class='shutdown_date'>Дата і час вимкнення</div>
|
||||||
|
<div class='turn_on_date'>Дата і час включення</div>
|
||||||
|
<div class='next_date'>Наступна дата робіт</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='shutdowns'>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div class='table-row flex'>
|
||||||
|
<div class='region'>Надвірнянський район</div>
|
||||||
|
<div class='city'>Гірна</div>
|
||||||
|
<div class='street'>Тевського</div>
|
||||||
|
<div class='house_number'>9</div>
|
||||||
|
<div class='building_part_number'></div>
|
||||||
|
<div class='dispatch_name'>ДСП 110/Пр."ЗБВIК "/30-413/Л-6</div>
|
||||||
|
<div class='type-shutdown'>Планове</div>
|
||||||
|
<div class='reason-shutdown reason-text-container'>
|
||||||
|
<div class='reason-text-hide'>Річна ремонтна програма</div>
|
||||||
|
</div>
|
||||||
|
<div class='placement_date'>
|
||||||
|
<div class='date'>2026.03.31</div>
|
||||||
|
<div class='time'>15:31</div>
|
||||||
|
</div>
|
||||||
|
<div class='shutdown_date'>
|
||||||
|
<div class='date'>2026.04.07</div>
|
||||||
|
<div class='time'>09:00</div>
|
||||||
|
</div>
|
||||||
|
<div class='turn_on_date'>
|
||||||
|
<div class='date'>2026.04.07</div>
|
||||||
|
<div class='time'>17:00</div>
|
||||||
|
</div>
|
||||||
|
<div class='next_date'>
|
||||||
|
<div class='reason-text-hide'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='table-row flex'>
|
||||||
|
<div class='region'>Гірнянський район</div>
|
||||||
|
<div class='city'>Гірна</div>
|
||||||
|
<div class='street'>Тевського</div>
|
||||||
|
<div class='house_number'>9</div>
|
||||||
|
<div class='building_part_number'></div>
|
||||||
|
<div class='dispatch_name'>ДСП 110/Пр."ЗБВIК "/30-413</div>
|
||||||
|
<div class='type-shutdown'>Планове</div>
|
||||||
|
<div class='reason-shutdown reason-text-container'>
|
||||||
|
<div class='reason-text-hide'>Річна ремонтна програма</div>
|
||||||
|
</div>
|
||||||
|
<div class='placement_date'>
|
||||||
|
<div class='date'>2026.03.31</div>
|
||||||
|
<div class='time'>15:31</div>
|
||||||
|
</div>
|
||||||
|
<div class='shutdown_date'>
|
||||||
|
<div class='date'>2026.04.07</div>
|
||||||
|
<div class='time'>09:00</div>
|
||||||
|
</div>
|
||||||
|
<div class='turn_on_date'>
|
||||||
|
<div class='date'>2026.04.07</div>
|
||||||
|
<div class='time'>17:00</div>
|
||||||
|
</div>
|
||||||
|
<div class='next_date'>
|
||||||
|
<div class='reason-text-hide'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='table-row flex'>
|
||||||
|
<div class='region'>Гірнянський район</div>
|
||||||
|
<div class='city'>Гірна</div>
|
||||||
|
<div class='street'>Тевського</div>
|
||||||
|
<div class='house_number'>9</div>
|
||||||
|
<div class='building_part_number'></div>
|
||||||
|
<div class='dispatch_name'>ДСП 110/Пр."ЗБВIК "/30-413/Л-6</div>
|
||||||
|
<div class='type-shutdown'>Планове</div>
|
||||||
|
<div class='reason-shutdown reason-text-container'>
|
||||||
|
<div class='reason-text-hide'>Річна ремонтна програма</div>
|
||||||
|
</div>
|
||||||
|
<div class='placement_date'>
|
||||||
|
<div class='date'>2026.03.30</div>
|
||||||
|
<div class='time'>16:53</div>
|
||||||
|
</div>
|
||||||
|
<div class='shutdown_date'>
|
||||||
|
<div class='date'>2026.04.06</div>
|
||||||
|
<div class='time'>09:00</div>
|
||||||
|
</div>
|
||||||
|
<div class='turn_on_date'>
|
||||||
|
<div class='date'>2026.04.06</div>
|
||||||
|
<div class='time'>17:00</div>
|
||||||
|
</div>
|
||||||
|
<div class='next_date'>
|
||||||
|
<div class='reason-text-hide'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='table-row flex'>
|
||||||
|
<div class='region'>Гірнянський район</div>
|
||||||
|
<div class='city'>Гірна</div>
|
||||||
|
<div class='street'>Тевського</div>
|
||||||
|
<div class='house_number'>9</div>
|
||||||
|
<div class='building_part_number'></div>
|
||||||
|
<div class='dispatch_name'>ДСП 110/Пр."ЗБВIК "/30-413</div>
|
||||||
|
<div class='type-shutdown'>Планове</div>
|
||||||
|
<div class='reason-shutdown reason-text-container'>
|
||||||
|
<div class='reason-text-hide'>Річна ремонтна програма</div>
|
||||||
|
</div>
|
||||||
|
<div class='placement_date'>
|
||||||
|
<div class='date'>2026.03.30</div>
|
||||||
|
<div class='time'>16:53</div>
|
||||||
|
</div>
|
||||||
|
<div class='shutdown_date'>
|
||||||
|
<div class='date'>2026.04.06</div>
|
||||||
|
<div class='time'>09:00</div>
|
||||||
|
</div>
|
||||||
|
<div class='turn_on_date'>
|
||||||
|
<div class='date'>2026.04.06</div>
|
||||||
|
<div class='time'>17:00</div>
|
||||||
|
</div>
|
||||||
|
<div class='next_date'>
|
||||||
|
<div class='reason-text-hide'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
1072
test/data_po_2days_02.html
Normal file
1072
test/data_po_2days_02.html
Normal file
File diff suppressed because it is too large
Load Diff
496
utils.py
Normal file
496
utils.py
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from io import StringIO
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
import app_config as cfg
|
||||||
|
from model import POData, GPVData
|
||||||
|
|
||||||
|
STATUS_SEND_OK = 0
|
||||||
|
STATUS_ERROR_SEND_TO_TG = 20
|
||||||
|
STATUS_ERROR_GPV_RESPONSE = 10
|
||||||
|
STATUS_ERROR_PO_RESPONSE = 30
|
||||||
|
STATUS_EMPTY_CONFIG = 5
|
||||||
|
STATUS_ERROR_SEND_TO_MX = 40
|
||||||
|
|
||||||
|
PARSED_PO_DATAS: List[POData] = []
|
||||||
|
PARSED_GPV_DATAS: List[GPVData] = []
|
||||||
|
|
||||||
|
LOG = cfg.LOG
|
||||||
|
|
||||||
|
def send_message_to_tg_get(msg: str):
|
||||||
|
url = f"https://api.telegram.org/bot{cfg.TG_TOKEN}/sendMessage?chat_id={cfg.TG_CHAT}&parse_mode=html&text={msg}"
|
||||||
|
if not cfg.IS_DEBUG:
|
||||||
|
return requests.get(url).status_code
|
||||||
|
else:
|
||||||
|
print(f"Send to TG GET: {msg}")
|
||||||
|
return 200
|
||||||
|
|
||||||
|
|
||||||
|
def send_message_to_tg_post(msg: str):
|
||||||
|
if not cfg.IS_DEBUG:
|
||||||
|
return requests.post(
|
||||||
|
url='https://api.telegram.org/bot{0}/{1}'.format(cfg.TG_TOKEN, 'sendMessage'),
|
||||||
|
data={'chat_id': {cfg.TG_CHAT}, 'text': {msg}, 'parse_mode': 'html'}
|
||||||
|
).status_code
|
||||||
|
else:
|
||||||
|
print(f"Send to TG POST: {msg}")
|
||||||
|
return 200
|
||||||
|
|
||||||
|
|
||||||
|
def send_matrix_notification(message: str) -> bool:
|
||||||
|
"""Send notification to Matrix"""
|
||||||
|
if not cfg.USE_MATRIX:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not cfg.IS_DEBUG:
|
||||||
|
try:
|
||||||
|
url = f"{cfg.MX_SERVER}/_matrix/client/r0/rooms/{cfg.MX_ROOM}/send/m.room.message"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {cfg.MX_TOKEN}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'msgtype': 'm.text',
|
||||||
|
'body': message,
|
||||||
|
'format': 'org.matrix.custom.html',
|
||||||
|
'formatted_body': message.replace('\n', '<br/>')
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
# logger.error(f"Error sending Matrix notification: {e}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f"Send to MX: {message}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_GPV_response_from_oe(account):
|
||||||
|
url = 'https://be-svitlo.oe.if.ua/schedule-by-search'
|
||||||
|
headers = {
|
||||||
|
"Host": "be-svitlo.oe.if.ua",
|
||||||
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0",
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Accept-Language": "en-US,en;q=0.5",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
|
||||||
|
"Referer": "https://svitlo.oe.if.ua/",
|
||||||
|
"Origin": "https://svitlo.oe.if.ua",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-site",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Priority": "u=0",
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"accountNumber": account,
|
||||||
|
"userSearchChoice": "pob",
|
||||||
|
"address": ""
|
||||||
|
}
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
return requests.post(url, headers=headers, data=data, verify=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_PO_response(): # Power Outage
|
||||||
|
utf8 = '✓'
|
||||||
|
url = 'https://oe.if.ua/uk/shutdowns_table'
|
||||||
|
params = {
|
||||||
|
'utf8': utf8,
|
||||||
|
'settlement': cfg.SETTLEMENT,
|
||||||
|
'street': cfg.STREET,
|
||||||
|
'house_number': cfg.HOUSE,
|
||||||
|
'building_part_number': cfg.BUILDING_PART_NUMBER,
|
||||||
|
'apartment': cfg.APARTMENT,
|
||||||
|
'commit': 'Пошук'
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
|
||||||
|
}
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
return requests.get(url=url, headers=headers, params=params)
|
||||||
|
|
||||||
|
|
||||||
|
def has_numbers(in_str):
|
||||||
|
return any(char.isdigit() for char in str(in_str))
|
||||||
|
|
||||||
|
|
||||||
|
def get_utf_chars(number, is_time=False):
|
||||||
|
digit_chars = ['\U00000030\U000020E3', '\U00000031\U000020E3', '\U00000032\U000020E3', '\U00000033\U000020E3',
|
||||||
|
'\U00000034\U000020E3', '\U00000035\U000020E3', '\U00000036\U000020E3', '\U00000037\U000020E3',
|
||||||
|
'\U00000038\U000020E3', '\U00000039\U000020E3']
|
||||||
|
res = ''
|
||||||
|
if is_time:
|
||||||
|
h = number.split(':')[0]
|
||||||
|
m = number.split(':')[1]
|
||||||
|
if m != 0:
|
||||||
|
value = str(h) + ':' + str(m)
|
||||||
|
else:
|
||||||
|
value = str(h) + ':00'
|
||||||
|
else:
|
||||||
|
value = str(number)
|
||||||
|
for c in value:
|
||||||
|
if c.isdigit and c not in ['.', ':']:
|
||||||
|
res += digit_chars[int(c)]
|
||||||
|
else:
|
||||||
|
res += c
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def get_digit_message(arr, is_time=False):
|
||||||
|
first = 0
|
||||||
|
if len(arr) > 0:
|
||||||
|
first = arr[0]
|
||||||
|
|
||||||
|
last = 0
|
||||||
|
if len(arr) > 1:
|
||||||
|
last = arr[1]
|
||||||
|
|
||||||
|
msg = 'з {num0} до {num1}'
|
||||||
|
return msg.format(num0=get_utf_chars(first, is_time), num1=get_utf_chars(last, is_time))
|
||||||
|
|
||||||
|
|
||||||
|
def get_GPV_message(approved_from, event_date, hours_off, hours_on, outage_queue):
|
||||||
|
queue = get_utf_chars(outage_queue)
|
||||||
|
message = f'\U000026A1 <b>ГПВ на {event_date} для {queue} черги</b>'
|
||||||
|
|
||||||
|
if len(hours_off) > 0:
|
||||||
|
message += '\nВимкнення:'
|
||||||
|
for i in range(len(hours_off)):
|
||||||
|
arr = [hours_off[i], hours_on[i]]
|
||||||
|
msg = get_digit_message(arr, True)
|
||||||
|
message += '\n' + msg
|
||||||
|
|
||||||
|
# message += (f'\n{MENTIONS}')
|
||||||
|
else:
|
||||||
|
message += '\nВимкнень немає \U0001F44C \U0001F483'
|
||||||
|
message += (f'\n<b><i>{approved_from}</i></b>')
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def get_address(city, street, house):
|
||||||
|
return f'{city}, {street}, {house}'
|
||||||
|
|
||||||
|
|
||||||
|
def is_outage_date_before_or_same(stop_date):
|
||||||
|
parsed_date = datetime.strptime(stop_date, "%Y.%m.%d").date()
|
||||||
|
current_date = datetime.now().date()
|
||||||
|
return parsed_date + timedelta(hours=1) >= current_date
|
||||||
|
|
||||||
|
|
||||||
|
def parse_po_data(root) -> List[POData]:
|
||||||
|
res: List[POData] = []
|
||||||
|
for i in range(len(root.findall(".//div[@class='table-row flex']"))):
|
||||||
|
po_data = POData(
|
||||||
|
city=root.xpath("//div[@class='table-row flex']/div[@class='city']/text()")[i],
|
||||||
|
street=root.xpath("//div[@class='table-row flex']/div[@class='street']/text()")[i],
|
||||||
|
house=root.xpath("//div[@class='table-row flex']/div[@class='house_number']/text()")[i],
|
||||||
|
po_type=root.xpath("//div[@class='table-row flex']/div[@class='type-shutdown']/text()")[i],
|
||||||
|
reason=root.xpath("//div[@class='table-row flex']/div[@class='reason-shutdown reason-text-container']/div[@class='reason-text-hide']/text()")[i],
|
||||||
|
placement_date=root.xpath("//div[@class='table-row flex']/div[@class='placement_date']/div[@class='date']/text()")[i],
|
||||||
|
start_date=root.xpath("//div[@class='table-row flex']/div[@class='shutdown_date']/div[@class='date']/text()")[i],
|
||||||
|
stop_date=root.xpath("//div[@class='table-row flex']/div[@class='turn_on_date']/div[@class='date']/text()")[i],
|
||||||
|
start_time=root.xpath("//div[@class='table-row flex']/div[@class='shutdown_date']/div[@class='time']/text()")[i],
|
||||||
|
stop_time=root.xpath("//div[@class='table-row flex']/div[@class='turn_on_date']/div[@class='time']/text()")[i])
|
||||||
|
|
||||||
|
if is_outage_date_before_or_same(po_data.stop_date):
|
||||||
|
po_data.need_to_send = True
|
||||||
|
|
||||||
|
if po_data not in res:
|
||||||
|
res.append(po_data)
|
||||||
|
|
||||||
|
res.sort(key=lambda pd: pd.start_date)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def load_last_datas(file_name):
|
||||||
|
if os.path.isfile(file_name):
|
||||||
|
with open(file_name, 'r') as f:
|
||||||
|
return f.read()
|
||||||
|
else:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def get_parsed_po_data():
|
||||||
|
return PARSED_PO_DATAS
|
||||||
|
|
||||||
|
|
||||||
|
def get_parsed_gpv_data():
|
||||||
|
return PARSED_GPV_DATAS
|
||||||
|
|
||||||
|
|
||||||
|
async def process_gpv():
|
||||||
|
if cfg.IS_DEBUG:
|
||||||
|
x = json.loads(
|
||||||
|
"""{
|
||||||
|
"current":
|
||||||
|
{
|
||||||
|
"gav":
|
||||||
|
{
|
||||||
|
"message": "Черга спеціальних аварійних відключень (СГАВ):",
|
||||||
|
"queue": null
|
||||||
|
},
|
||||||
|
"hasQueue": "yes",
|
||||||
|
"note": "Станом на 21:05 14.01.2026 за вказаним особовим рахунком '30014180' споживач підпадає під чергу '5.1' Графіку погодинного відключення(ГПВ)",
|
||||||
|
"queue": 5,
|
||||||
|
"sgav":
|
||||||
|
{
|
||||||
|
"message": "Черга спеціальних аварійних відключень (СГАВ):",
|
||||||
|
"queue": null
|
||||||
|
},
|
||||||
|
"subQueue": 1
|
||||||
|
},
|
||||||
|
"schedule":
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"createdAt": "14.01.2026 19:11",
|
||||||
|
"eventDate": "15.04.2026",
|
||||||
|
"queues":
|
||||||
|
{
|
||||||
|
"5.1":
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"from": "07:30",
|
||||||
|
"shutdownHours": "07:30-11:00",
|
||||||
|
"status": 1,
|
||||||
|
"to": "11:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "14:30",
|
||||||
|
"shutdownHours": "14:30-20:00",
|
||||||
|
"status": 1,
|
||||||
|
"to": "20:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "22:00",
|
||||||
|
"shutdownHours": "22:00-00:00",
|
||||||
|
"status": 1,
|
||||||
|
"to": "00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scheduleApprovedSince": "14.04.2026 19:11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"createdAt": "13.04.2026 20:28",
|
||||||
|
"eventDate": "14.04.2026",
|
||||||
|
"queues":
|
||||||
|
{
|
||||||
|
"5.1":
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"from": "03:00",
|
||||||
|
"shutdownHours": "03:00-07:00",
|
||||||
|
"status": 1,
|
||||||
|
"to": "07:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "11:00",
|
||||||
|
"shutdownHours": "11:00-17:30",
|
||||||
|
"status": 1,
|
||||||
|
"to": "17:30"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scheduleApprovedSince": "14.04.2026 09:56"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = get_GPV_response_from_oe(cfg.ACCOUNT)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print("code=" + str(response.status_code))
|
||||||
|
return STATUS_ERROR_GPV_RESPONSE
|
||||||
|
|
||||||
|
x = response.json()
|
||||||
|
|
||||||
|
outage_queue = str(x["current"]["queue"])
|
||||||
|
if x["current"]["subQueue"]:
|
||||||
|
outage_queue += '.' + str(x["current"]["subQueue"])
|
||||||
|
|
||||||
|
datas = ''
|
||||||
|
if 'queues' in str(x["schedule"]):
|
||||||
|
datas = sorted(x["schedule"], key=lambda item: item['eventDate'])
|
||||||
|
|
||||||
|
for entry in datas:
|
||||||
|
hours_off = []
|
||||||
|
hours_on = []
|
||||||
|
if len(entry) > 0:
|
||||||
|
approved_from = 'Запроваджено ' + entry['scheduleApprovedSince']
|
||||||
|
event_date = entry['eventDate']
|
||||||
|
hours_list = entry['queues'][outage_queue]
|
||||||
|
else:
|
||||||
|
hours_list = []
|
||||||
|
approved_from = 'Hемає даних про дату запровадження'
|
||||||
|
event_date = datetime.today().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
if cfg.IS_DEBUG:
|
||||||
|
print(f'hours_list{hours_list}, approved_from={approved_from}, event_date={event_date}')
|
||||||
|
|
||||||
|
for h in hours_list:
|
||||||
|
if h["status"] == 1:
|
||||||
|
hours_off.append(h["from"])
|
||||||
|
hours_on.append(h["to"])
|
||||||
|
|
||||||
|
if cfg.IS_DEBUG:
|
||||||
|
print(f'hours_off = {hours_off}')
|
||||||
|
print(f'hours_on = {hours_on}')
|
||||||
|
|
||||||
|
update_gpv_data(PARSED_GPV_DATAS, GPVData(approved_from=approved_from, event_date=event_date, hours_off=hours_off, hours_on=hours_on, outage_queue=outage_queue))
|
||||||
|
|
||||||
|
check_and_send_gpv_message()
|
||||||
|
return STATUS_SEND_OK
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_send_gpv_message():
|
||||||
|
for el in PARSED_GPV_DATAS:
|
||||||
|
message = get_GPV_message(el.approved_from, el.event_date, el.hours_off, el.hours_on, el.outage_queue)
|
||||||
|
if el.need_to_send and (el.start_ts[-1] + 3600.0 >= datetime.now().timestamp()):
|
||||||
|
tg_response = send_message_to_tg_get(message)
|
||||||
|
if tg_response == 200:
|
||||||
|
el.set_send()
|
||||||
|
LOG.debug(f'TG message send {message}')
|
||||||
|
else:
|
||||||
|
LOG.error(f'TG send error code: {STATUS_ERROR_SEND_TO_TG}')
|
||||||
|
|
||||||
|
if cfg.USE_MATRIX and not cfg.IS_DEBUG:
|
||||||
|
res = send_matrix_notification(message)
|
||||||
|
if not res:
|
||||||
|
LOG.error(f'MX send error code: {STATUS_ERROR_SEND_TO_MX}')
|
||||||
|
|
||||||
|
|
||||||
|
def update_gpv_data(data_list: List[GPVData], new_data: GPVData):
|
||||||
|
# find by TS
|
||||||
|
obj = next((obj for obj in data_list if obj.start_ts == new_data.start_ts), None)
|
||||||
|
if obj:
|
||||||
|
# if exists - update:
|
||||||
|
old_data = data_list[data_list.index(obj)]
|
||||||
|
if not old_data.need_to_send: # notification already send
|
||||||
|
if old_data.start_ts != new_data.start_ts:
|
||||||
|
data_list[data_list.index(obj)] = new_data
|
||||||
|
LOG.debug(f'Updated element {old_data} in list with new data {new_data}')
|
||||||
|
else:
|
||||||
|
LOG.debug(f'Element {old_data} was not updated due to the same')
|
||||||
|
else:
|
||||||
|
data_list[data_list.index(obj)] = new_data
|
||||||
|
else:
|
||||||
|
# if not exists - add
|
||||||
|
data_list.append(new_data)
|
||||||
|
LOG.debug(f'New element appended to list {new_data}')
|
||||||
|
# data_list SORT
|
||||||
|
|
||||||
|
# remove OLD entries
|
||||||
|
ts = datetime.now().timestamp()
|
||||||
|
for el in data_list[:]:
|
||||||
|
if el.start_ts and (el.start_ts[-1] + 1800) < ts:
|
||||||
|
LOG.debug(f'Old element removed from list {el}')
|
||||||
|
data_list.remove(el)
|
||||||
|
|
||||||
|
|
||||||
|
def get_message_with_po(el: POData):
|
||||||
|
address = get_address(el.city, el.street, el.house)
|
||||||
|
if el.need_to_send:
|
||||||
|
message = (
|
||||||
|
f'\U000026A0 <b>Увага!</b>\nНа {el.start_date} за адресою {address} заплановано <b>{el.po_type}</b> відключення (заявка від {el.placement_date}).\n'
|
||||||
|
f'Причина відключення: {el.reason}\n'
|
||||||
|
f'Початок вимкнення <b>{el.start_date} об {el.start_time}</b>\n'
|
||||||
|
f'Кінець вимкнення <b>{el.stop_date} об {el.stop_time}</b>')
|
||||||
|
else:
|
||||||
|
message = f'На {el.start_date} Планових вимкнень немає \U0001F44C \U0001F483'
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
async def process_po():
|
||||||
|
if not cfg.IS_DEBUG:
|
||||||
|
response = get_PO_response()
|
||||||
|
if response.status_code != 200:
|
||||||
|
return STATUS_ERROR_PO_RESPONSE
|
||||||
|
html = response.text
|
||||||
|
parser = etree.HTMLParser()
|
||||||
|
root = etree.parse(StringIO(html), parser)
|
||||||
|
else:
|
||||||
|
# html = "<html><body><div id='shutdowns-table'><div class='table'><div class='flex table-header'><div class='region'>Район</div><div class='city'>Населений пункт</div><div class='street'>Вулиця</div><div class='house_number'>№ буд</div>" \
|
||||||
|
# "<div class='building_part_number'>Корпус</div><div class='dispatch_name'>Диспетчерська назва об'єкту</div><div class='type-shutdown'>Вид робіт</div><div class='reason-shutdown'>Причина робіт</div><div class='placement_date'>Дата розміщення</div>" \
|
||||||
|
# "<div class='shutdown_date'>Дата і час вимкнення</div><div class='turn_on_date'>Дата і час включення</div><div class='next_date'>Наступна дата робіт</div></div></div><div class='shutdowns'><div><div><div class='table-row flex'><div class='region'>Піднянський район</div>" \
|
||||||
|
# "<div class='city'>Гірна</div><div class='street'>Тевського</div><div class='house_number'>9</div><div class='building_part_number'/><div class='dispatch_name'>ДСП 110/Пр."ЗБВIК"/30-413/Л-6</div><div class='type-shutdown'>Планове</div>" \
|
||||||
|
# "<div class='reason-shutdown reason-text-container'><div class='reason-text-hide'>Поточний ремонт</div></div><div class='placement_date'><div class='date'>2025.03.24</div><div class='time'>11:50</div></div><div class='shutdown_date'><div class='date'>2026.04.10</div>" \
|
||||||
|
# "<div class='time'>09:00</div></div><div class='turn_on_date'><div class='date'>2026.04.10</div><div class='time'>18:00</div></div><div class='next_date'><div class='reason-text-hide'/></div></div></div></div></div></div></body></html>"
|
||||||
|
with open('test/data_po_2days_02.html', 'r') as file:
|
||||||
|
html = file.read()
|
||||||
|
root = etree.fromstring(html)
|
||||||
|
|
||||||
|
global PARSED_PO_DATAS
|
||||||
|
new_data = parse_po_data(root)
|
||||||
|
for el in new_data:
|
||||||
|
await update_po_data(PARSED_PO_DATAS, el)
|
||||||
|
|
||||||
|
check_and_send_po_message()
|
||||||
|
|
||||||
|
return STATUS_SEND_OK
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_send_po_message():
|
||||||
|
for el in PARSED_PO_DATAS:
|
||||||
|
if el.need_to_send:
|
||||||
|
message = get_message_with_po(el)
|
||||||
|
tg_response = send_message_to_tg_get(message)
|
||||||
|
if tg_response == 200:
|
||||||
|
el.set_sent()
|
||||||
|
el.set_notified_now()
|
||||||
|
LOG.debug(f'TG message send {message}')
|
||||||
|
else:
|
||||||
|
LOG.error(f'TG send error code: {STATUS_ERROR_SEND_TO_TG}')
|
||||||
|
|
||||||
|
if cfg.USE_MATRIX:
|
||||||
|
res = send_matrix_notification(message)
|
||||||
|
if not res:
|
||||||
|
LOG.error(f'MX send error code: {STATUS_ERROR_SEND_TO_MX}')
|
||||||
|
|
||||||
|
|
||||||
|
async def update_po_data(data_list:List[POData], new_data: POData):
|
||||||
|
# find by TS
|
||||||
|
obj = next((obj for obj in data_list if obj.start_ts == new_data.start_ts), None)
|
||||||
|
if obj:
|
||||||
|
# if exists - update
|
||||||
|
old_data = data_list[data_list.index(obj)]
|
||||||
|
if not old_data.need_to_send: # notification already send
|
||||||
|
if old_data != new_data:
|
||||||
|
# update 'need_to_send' to re-send notification every day
|
||||||
|
if ((not old_data.notified_at + timedelta(days=1) <= datetime.now()) or
|
||||||
|
# or if current hour - is the start data processing hour with firs minute in cfg.PROCESSING_MINUTES list
|
||||||
|
(cfg.PROCESS_START_HOUR and datetime.now().strftime('%H') == cfg.PROCESS_START_HOUR and datetime.now().strftime('%M') == cfg.PROCESSING_MINUTES[0])):
|
||||||
|
new_data.set_sent()
|
||||||
|
new_data.update_notified_at(old_data)
|
||||||
|
data_list[data_list.index(obj)] = new_data
|
||||||
|
LOG.debug(f'Updated element {old_data} in list with new data {new_data}')
|
||||||
|
else:
|
||||||
|
LOG.debug(f'Element {old_data} was not updated due to the same')
|
||||||
|
else:
|
||||||
|
data_list[data_list.index(obj)] = new_data
|
||||||
|
else:
|
||||||
|
# if not exists - add
|
||||||
|
data_list.append(new_data)
|
||||||
|
LOG.debug(f'New element appended to list {new_data}')
|
||||||
|
# data_list SORT
|
||||||
|
|
||||||
|
# remove OLD entries
|
||||||
|
ts = datetime.now().timestamp()
|
||||||
|
for el in data_list[:]:
|
||||||
|
if el.start_ts and (el.start_ts + 3600) <= ts:
|
||||||
|
LOG.debug(f'Old element removed from list {el}')
|
||||||
|
data_list.remove(el)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user