Adds point_alarm.py (flattening, secret masking, SQLite upsert store, paged fetch with 403 fallback, CSV/TXT export, shared filter loader), nowcast-point-page.py CLI and unit tests. Register will read the shared filter from nowcast-point-alarm-filter.json instead of repeating it in the CSV.
168 lines
6.0 KiB
Python
168 lines
6.0 KiB
Python
"""Helpers shared by the nowcast alarm clients (geo and point)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import json
|
||
import os
|
||
import tempfile
|
||
from copy import deepcopy
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import urlsplit
|
||
from uuid import UUID
|
||
|
||
from common import ApiError, ConfigurationError, IklimClient
|
||
|
||
|
||
AUTHENTICATION_FIELDS = {
|
||
"BASIC": ("username", "password"),
|
||
"JWT_TOKEN": ("jwtToken",),
|
||
"API_KEY": ("apiKey", "asQueryParameter", "asHeader"),
|
||
"HMAC_SIGNATURE": ("clientId", "secret", "algorithm"),
|
||
}
|
||
|
||
|
||
def read_json_object(path: Path, description: str) -> dict[str, Any]:
|
||
try:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
except FileNotFoundError as exc:
|
||
raise ConfigurationError(f"{description} dosyası bulunamadı: {path}") from exc
|
||
except json.JSONDecodeError as exc:
|
||
raise ConfigurationError(
|
||
f"{description} geçerli JSON değil ({path}:{exc.lineno})."
|
||
) from exc
|
||
if not isinstance(data, dict):
|
||
raise ConfigurationError(f"{description} kök değeri JSON nesnesi olmalıdır.")
|
||
return data
|
||
|
||
|
||
def required_config_value(data: dict[str, Any], key: str, location: str) -> Any:
|
||
value = data.get(key)
|
||
if value is None or (isinstance(value, str) and not value.strip()):
|
||
raise ConfigurationError(f"{location}.{key} eksik veya boş.")
|
||
if isinstance(value, str) and "change-me" in value.strip().lower():
|
||
raise ConfigurationError(f"{location}.{key} placeholder değeri değiştirilmemiş.")
|
||
return value
|
||
|
||
|
||
def load_webhook_config(path: Path) -> dict[str, Any]:
|
||
config = read_json_object(path, "Webhook config")
|
||
url = str(required_config_value(config, "url", "webhook"))
|
||
parsed_url = urlsplit(url)
|
||
if parsed_url.scheme != "https" or not parsed_url.netloc:
|
||
raise ConfigurationError("webhook.url geçerli bir HTTPS adresi olmalıdır.")
|
||
|
||
http_method = str(required_config_value(config, "httpMethod", "webhook")).upper()
|
||
if http_method not in {"POST", "PUT", "GET"}:
|
||
raise ConfigurationError("webhook.httpMethod POST, PUT veya GET olmalıdır.")
|
||
|
||
authentication = config.get("authentication")
|
||
if not isinstance(authentication, dict):
|
||
raise ConfigurationError("webhook.authentication JSON nesnesi olmalıdır.")
|
||
selected = str(
|
||
required_config_value(authentication, "selected", "webhook.authentication")
|
||
).upper()
|
||
if selected not in AUTHENTICATION_FIELDS:
|
||
allowed = ", ".join(AUTHENTICATION_FIELDS)
|
||
raise ConfigurationError(
|
||
f"webhook.authentication.selected şu değerlerden biri olmalıdır: {allowed}."
|
||
)
|
||
options = authentication.get("options")
|
||
if not isinstance(options, dict) or not isinstance(options.get(selected), dict):
|
||
raise ConfigurationError(
|
||
f"webhook.authentication.options.{selected} JSON nesnesi olmalıdır."
|
||
)
|
||
selected_options = options[selected]
|
||
auth_payload: dict[str, Any] = {"type": selected}
|
||
for field in AUTHENTICATION_FIELDS[selected]:
|
||
auth_payload[field] = required_config_value(
|
||
selected_options, field, f"webhook.authentication.options.{selected}"
|
||
)
|
||
|
||
payload: dict[str, Any] = {
|
||
"url": url,
|
||
"httpMethod": http_method,
|
||
"contentType": str(
|
||
required_config_value(config, "contentType", "webhook")
|
||
),
|
||
"authentication": auth_payload,
|
||
}
|
||
for optional_field in ("requestHeaders", "deliveryPolicy"):
|
||
if optional_field in config:
|
||
if not isinstance(config[optional_field], dict):
|
||
raise ConfigurationError(
|
||
f"webhook.{optional_field} JSON nesnesi olmalıdır."
|
||
)
|
||
payload[optional_field] = deepcopy(config[optional_field])
|
||
return payload
|
||
|
||
|
||
def as_uuid(value: Any, *, version: int | None = None) -> str | None:
|
||
if not isinstance(value, str):
|
||
return None
|
||
try:
|
||
parsed = UUID(value)
|
||
except ValueError:
|
||
return None
|
||
if version is not None and parsed.version != version:
|
||
return None
|
||
return value
|
||
|
||
|
||
def response_warnings(*objects: Any) -> list[str]:
|
||
warnings: list[str] = []
|
||
for data in objects:
|
||
if not isinstance(data, dict):
|
||
continue
|
||
for key in ("warning", "warnings"):
|
||
value = data.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
warnings.append(value.strip())
|
||
elif isinstance(value, list):
|
||
warnings.extend(str(item) for item in value if str(item).strip())
|
||
return warnings
|
||
|
||
|
||
def write_csv_atomic(
|
||
path: Path, rows: list[dict[str, Any]], fieldnames: list[str]
|
||
) -> None:
|
||
"""Rewrite a semicolon separated CSV through a temp file in the same directory."""
|
||
temp_path: Path | None = None
|
||
try:
|
||
with tempfile.NamedTemporaryFile(
|
||
"w",
|
||
encoding="utf-8-sig",
|
||
newline="",
|
||
dir=path.parent,
|
||
prefix=f".{path.name}.",
|
||
suffix=".tmp",
|
||
delete=False,
|
||
) as temp_file:
|
||
temp_path = Path(temp_file.name)
|
||
writer = csv.DictWriter(
|
||
temp_file,
|
||
fieldnames=fieldnames,
|
||
delimiter=";",
|
||
extrasaction="ignore",
|
||
)
|
||
writer.writeheader()
|
||
writer.writerows(rows)
|
||
temp_file.flush()
|
||
os.fsync(temp_file.fileno())
|
||
os.replace(temp_path, path)
|
||
finally:
|
||
if temp_path and temp_path.exists():
|
||
temp_path.unlink()
|
||
|
||
|
||
def fetch_account_id(client: IklimClient) -> str:
|
||
"""Return the caller's account id from /v1/users/me."""
|
||
response = client.request_json("GET", "/v1/users/me")
|
||
data = response.data
|
||
account = data.get("account") if isinstance(data, dict) else None
|
||
account_id = account.get("id") if isinstance(account, dict) else None
|
||
if not as_uuid(account_id):
|
||
raise ApiError("/v1/users/me yanıtında geçerli account.id bulunamadı.")
|
||
return account_id
|