From e3367c6a2edb5cec63cf0808fc85b27e4b6124ae Mon Sep 17 00:00:00 2001 From: Anry Das Date: Sun, 26 Apr 2026 10:08:54 +0300 Subject: [PATCH] Initial commit --- .gitignore | 1 + app_config.py | 137 +++++ config_file.py | 74 +++ configs/config.yaml | 31 ++ configs/log.yaml | 34 ++ main.py | 214 +++++++ model.py | 73 +++ requirements.txt | 11 + static/favicon.png | Bin 0 -> 16223 bytes templates/info.html | 46 ++ test/data_po_2days_01.html | 149 +++++ test/data_po_2days_02.html | 1072 ++++++++++++++++++++++++++++++++++++ utils.py | 496 +++++++++++++++++ 13 files changed, 2338 insertions(+) create mode 100644 .gitignore create mode 100644 app_config.py create mode 100644 config_file.py create mode 100644 configs/config.yaml create mode 100644 configs/log.yaml create mode 100644 main.py create mode 100644 model.py create mode 100644 requirements.txt create mode 100644 static/favicon.png create mode 100644 templates/info.html create mode 100644 test/data_po_2days_01.html create mode 100644 test/data_po_2days_02.html create mode 100644 utils.py 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 0000000000000000000000000000000000000000..d90d6d2c6ef5bcd3581dd8825d72e182496c9cee GIT binary patch literal 16223 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 + + + + {{ 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