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