Initial commit

This commit is contained in:
Anry Das
2026-04-26 10:08:54 +03:00
commit e3367c6a2e
13 changed files with 2338 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/configs/secrets.yaml

137
app_config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

46
templates/info.html Normal file
View 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
View 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/Пр.&quot;ЗБВIК &quot;/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/Пр.&quot;ЗБВIК &quot;/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/Пр.&quot;ЗБВIК &quot;/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/Пр.&quot;ЗБВIК &quot;/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

File diff suppressed because it is too large Load Diff

496
utils.py Normal file
View 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/Пр.&quot;ЗБВIК&quot;/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