commit e3367c6a2edb5cec63cf0808fc85b27e4b6124ae Author: Anry Das Date: Sun Apr 26 10:08:54 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23e3384 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/configs/secrets.yaml diff --git a/app_config.py b/app_config.py new file mode 100644 index 0000000..10b8f93 --- /dev/null +++ b/app_config.py @@ -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() diff --git a/config_file.py b/config_file.py new file mode 100644 index 0000000..0654096 --- /dev/null +++ b/config_file.py @@ -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() \ No newline at end of file diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..136f28c --- /dev/null +++ b/configs/config.yaml @@ -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 \ No newline at end of file diff --git a/configs/log.yaml b/configs/log.yaml new file mode 100644 index 0000000..e109343 --- /dev/null +++ b/configs/log.yaml @@ -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 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..8249b74 --- /dev/null +++ b/main.py @@ -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() diff --git a/model.py b/model.py new file mode 100644 index 0000000..6e74174 --- /dev/null +++ b/model.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ebfa3e9 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..d90d6d2 Binary files /dev/null and b/static/favicon.png differ diff --git a/templates/info.html b/templates/info.html new file mode 100644 index 0000000..4549807 --- /dev/null +++ b/templates/info.html @@ -0,0 +1,46 @@ + + + + + {{ title }} + + +
+

{{ title }}

+
+

Info

+ + {% for k,v in help_messages.items() %} + + + + {% endfor %} +
{{ k }}:{{ v }}
+
+

API EPs

+ {% for e in EPs %} + {{ path_prefix }}{{ e.path }}
+ {% endfor %} +
+ {{ info | safe }} +

Pover Outages:

+ {% if PO_events %} + {% for e in PO_events %} + {{ e }}
+ {% endfor %} + {% else %} +

No Pover Outages planned

+ {% endif %} +
+

GPVs:

+ {% if GPV_events %} + {% for e in GPV_events %} + {{ e }}
+ {% endfor %} + {% else %} +

No GPVs planned

+ {% endif %} +
+
+ + \ No newline at end of file diff --git a/test/data_po_2days_01.html b/test/data_po_2days_01.html new file mode 100644 index 0000000..48b6447 --- /dev/null +++ b/test/data_po_2days_01.html @@ -0,0 +1,149 @@ + + +
+ Повернутися на попередню сторінку +
+
+

Вимкнення за вашою адресою станом на 04.04.2026 10:03

+
+
Надвірна, вул. Грушевського, буд. 9 кв. 20
+
+
+ +
+
+
+
+
Район
+
Населений пункт
+
Вулиця
+
№ буд
+
Корпус
+
Диспетчерська назва об'єкту
+
Вид робіт
+
Причина робіт
+
Дата розміщення
+
Дата і час вимкнення
+
Дата і час включення
+
Наступна дата робіт
+
+
+
+
+
+
+
Надвірнянський район
+
Гірна
+
Тевського
+
9
+
+
ДСП 110/Пр."ЗБВIК "/30-413/Л-6
+
Планове
+
+
Річна ремонтна програма
+
+
+
2026.03.31
+
15:31
+
+
+
2026.04.07
+
09:00
+
+
+
2026.04.07
+
17:00
+
+
+
+
+
+
+
Гірнянський район
+
Гірна
+
Тевського
+
9
+
+
ДСП 110/Пр."ЗБВIК "/30-413
+
Планове
+
+
Річна ремонтна програма
+
+
+
2026.03.31
+
15:31
+
+
+
2026.04.07
+
09:00
+
+
+
2026.04.07
+
17:00
+
+
+
+
+
+
+
Гірнянський район
+
Гірна
+
Тевського
+
9
+
+
ДСП 110/Пр."ЗБВIК "/30-413/Л-6
+
Планове
+
+
Річна ремонтна програма
+
+
+
2026.03.30
+
16:53
+
+
+
2026.04.06
+
09:00
+
+
+
2026.04.06
+
17:00
+
+
+
+
+
+
+
Гірнянський район
+
Гірна
+
Тевського
+
9
+
+
ДСП 110/Пр."ЗБВIК "/30-413
+
Планове
+
+
Річна ремонтна програма
+
+
+
2026.03.30
+
16:53
+
+
+
2026.04.06
+
09:00
+
+
+
2026.04.06
+
17:00
+
+
+
+
+
+
+
+
+
+
+ diff --git a/test/data_po_2days_02.html b/test/data_po_2days_02.html new file mode 100644 index 0000000..84ae78e --- /dev/null +++ b/test/data_po_2days_02.html @@ -0,0 +1,1072 @@ + + + +
+ +
+ + Logo mob + +
+ +
+
+ + Кабінет послуги з приєднання + + + Особистий кабінет + +
+ Повернутися на попередню сторінку +
+
+

Вимкнення за вашою адресою станом на 14.04.2026 10:52

+
+
Гірна, вул. Тевського, буд. 9 кв. 20
+
+
+ +
+
+
+
+
Район
+
Населений пункт
+
Вулиця
+
№ буд
+
Корпус
+
Диспетчерська назва об'єкту
+
Вид робіт
+
Причина робіт
+
Дата розміщення
+
Дата і час вимкнення
+
Дата і час включення
+
Наступна дата робіт
+
+
+
+
+
+
+
Гірнянський район
+
Гірна
+
Тевського
+
9
+
+
ДСП 110/Пр."ЗБВIК "/30-413/Л-6
+
Планове
+
+
Річна ремонтна програма
+
+
+
2026.04.14
+
07:00
+
+
+
2026.04.24
+
09:00
+
+
+
2026.04.24
+
18:00
+
+
+
+
+
+
+
Гірнянський район
+
Гірна
+
М.Грушевського
+
9
+
+
ДСП 110/Пр."ЗБВIК "/30-413
+
Планове
+
+
Річна ремонтна програма
+
+
+
2026.04.14
+
07:00
+
+
+
2026.04.24
+
09:00
+
+
+
2026.04.24
+
18:00
+
+
+
+
+
+
+
+
+
+
+ + diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..770f8bd --- /dev/null +++ b/utils.py @@ -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', '
') + } + + 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 ГПВ на {event_date} для {queue} черги' + + 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{approved_from}') + 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 Увага!\nНа {el.start_date} за адресою {address} заплановано {el.po_type} відключення (заявка від {el.placement_date}).\n' + f'Причина відключення: {el.reason}\n' + f'Початок вимкнення {el.start_date} об {el.start_time}\n' + f'Кінець вимкнення {el.stop_date} об {el.stop_time}') + 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 = "
Район
Населений пункт
Вулиця
№ буд
" \ + # "
Корпус
Диспетчерська назва об'єкту
Вид робіт
Причина робіт
Дата розміщення
" \ + # "
Дата і час вимкнення
Дата і час включення
Наступна дата робіт
Піднянський район
" \ + # "
Гірна
Тевського
9
ДСП 110/Пр."ЗБВIК"/30-413/Л-6
Планове
" \ + # "
Поточний ремонт
2025.03.24
11:50
2026.04.10
" \ + # "
09:00
2026.04.10
18:00
" + 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