Load config, harden processing and notifications

- Actually load configs/config.yaml at startup (init_config was commented out)
- Add timeouts and error handling to all HTTP calls; run blocking requests
  in threads; serialize processing with a lock; run initial check in background
- URL-encode Telegram messages and HTML-escape scraped data
- Fix GPV crash on empty schedule, 00:00 end time, date sorting and fallback format
- Parse PO table per row; fix daily reminder check and change detection
- Use per-instance timestamps in dataclasses; robust config value parsing
- Matrix: use v3 PUT send endpoint; strip secrets from logged errors
- Resolve templates/static/test paths relative to the project; use lifespan
- Move debug GPV data to test/data_gpv.json; add CLAUDE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anry Das
2026-10-13 14:04:18 +03:00
co-authored by Claude Opus 5.5
parent e3367c6a2e
commit 22fc8b38bf
8 changed files with 683 additions and 444 deletions
+67 -20
View File
@@ -1,15 +1,28 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Self
from datetime import datetime, timedelta
from typing import List, Optional, Self
import app_config as cfg
LOG = cfg.LOG
PO_DATETIME_FORMAT = '%Y.%m.%d %H:%M'
GPV_DATETIME_FORMAT = '%d.%m.%Y %H:%M'
GPV_DATE_FORMAT = '%d.%m.%Y'
def parse_ts(value: str, fmt: str) -> Optional[float]:
try:
return datetime.strptime(value, fmt).timestamp()
except (TypeError, ValueError):
return None
@dataclass
class POData:
city: str = field(repr=False)
street: str = field(repr=False)
house: int = field(repr=False)
house: str = field(repr=False)
po_type: str = field(repr=False)
reason: str = field(repr=False)
placement_date: str
@@ -17,16 +30,23 @@ class POData:
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)
# service fields are not compared: equality means the same outage content
notified_at: Optional[datetime] = field(repr=False, default=None, compare=False)
updated_at: datetime = field(repr=False, default_factory=datetime.now, compare=False)
need_to_send: bool = field(repr=False, default=True, compare=False)
start_ts: float = field(init=False, default=0.0, compare=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:
ts = parse_ts(f'{self.start_date} {self.start_time}', PO_DATETIME_FORMAT)
if ts is None:
LOG.error(f'wrong start_date or start_time in {self}')
else:
self.start_ts = ts
@property
def key(self):
"""Identity of the outage: the same address and start time"""
return self.start_ts, self.city, self.street, self.house
def set_sent(self):
self.need_to_send = False
@@ -45,17 +65,44 @@ class GPVData:
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)
notified_at: Optional[datetime] = field(repr=False, default=None, compare=False)
updated_at: datetime = field(repr=False, default_factory=datetime.now, compare=False)
need_to_send: bool = field(repr=False, default=True, compare=False)
# timestamps of power-on moments (ends of outages)
start_ts: List[float] = field(default_factory=list, init=False, compare=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}')
day_start = parse_ts(f'{self.event_date} 00:00', GPV_DATETIME_FORMAT)
if day_start is None:
LOG.error(f'wrong event_date in {self}')
return
for hour_off, hour_on in zip(self.hours_off, self.hours_on):
ts_off = parse_ts(f'{self.event_date} {hour_off}', GPV_DATETIME_FORMAT)
ts_on = parse_ts(f'{self.event_date} {hour_on}', GPV_DATETIME_FORMAT)
if ts_on is None:
LOG.error(f'wrong time {hour_on!r} in {self}')
continue
if ts_off is not None and ts_on <= ts_off:
# e.g. '22:00-00:00': power is on at the next day
ts_on = (datetime.fromtimestamp(ts_on) + timedelta(days=1)).timestamp()
self.start_ts.append(ts_on)
@property
def key(self):
return self.event_date, self.outage_queue
@property
def actual_till_ts(self) -> Optional[float]:
"""Moment after which the schedule is not actual: the last power-on or end of the event day"""
if self.start_ts:
return max(self.start_ts)
day_start = parse_ts(f'{self.event_date} 00:00', GPV_DATETIME_FORMAT)
if day_start is None:
return None
return (datetime.fromtimestamp(day_start) + timedelta(days=1)).timestamp()
def is_same_schedule(self, other: Self) -> bool:
return self.hours_off == other.hours_off and self.hours_on == other.hours_on
def set_send(self):
self.need_to_send = False
@@ -66,7 +113,7 @@ class GPVData:
class JobData:
name: str
trigger: str
next: str
next: Optional[datetime]
if __name__ == '__main__':