feat: add nowcast point alarm register client

Registers one point alarm per CSV row (recipientId, lat, lng, radius) with the shared filter JSON and the existing webhook bootstrap, records status, httpStatus, registrationId and errorBody back into the CSV and recovers ambiguous failures only on an identical recipient, point and radius match.
This commit is contained in:
2026-10-23 19:13:01 +03:00
parent 32a587a1c6
commit ba249128a1
4 changed files with 341 additions and 2 deletions
+182 -1
View File
@@ -7,15 +7,26 @@ import json
import logging
import sqlite3
import sys
from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Iterable, TextIO
from urllib.parse import quote
from uuid import uuid4
from common import ApiError, ConfigurationError, IklimClient
from common.client import ApiResponse
from nowcast_common import fetch_account_id, read_json_object
from common.client import RETRYABLE_STATUS_CODES
from common.config import env_bool
from nowcast_common import (
as_uuid,
fetch_account_id,
load_webhook_config,
read_json_object,
response_warnings,
write_csv_atomic,
)
LOGGER = logging.getLogger(__name__)
@@ -567,6 +578,8 @@ def lookup_rows(
LOGGER.warning("%s için sonuç %s (HTTP %s).", query, status, exc.status_code)
continue
data = response.data
if isinstance(data, dict) and "registrations" in data:
data = data["registrations"]
items = data if isinstance(data, list) else [data]
items = [item for item in items if isinstance(item, dict)]
if not items:
@@ -584,3 +597,171 @@ def lookup_rows(
store.commit()
webhooks = store.webhooks(account_id) if account_id else store.webhooks()
return rows, webhooks
REGISTER_REQUIRED_COLUMNS = ["recipientId", "lat", "lng", "radius"]
REGISTER_RESULT_COLUMNS = ["status", "httpStatus", "registrationId", "errorBody"]
def error_body_text(exc: ApiError) -> str:
if exc.response_data is None:
return str(exc)
if isinstance(exc.response_data, str):
return exc.response_data
return json.dumps(exc.response_data, ensure_ascii=False)
def parse_point_row(row: dict[str, Any]) -> tuple[str, float, float, float]:
"""Validate one register CSV row and return (recipientId, lat, lng, radius)."""
recipient_id = str(row.get("recipientId", "")).strip()
if not recipient_id:
raise ConfigurationError("recipientId boş.")
if not recipient_id.isascii():
raise ConfigurationError("recipientId US-ASCII karakterlerinden oluşmalıdır.")
try:
lat = float(str(row.get("lat", "")).strip().replace(",", "."))
lng = float(str(row.get("lng", "")).strip().replace(",", "."))
radius = float(str(row.get("radius", "")).strip().replace(",", "."))
except ValueError as exc:
raise ConfigurationError("lat, lng ve radius sayı olmalıdır.") from exc
if not -90 <= lat <= 90 or not -180 <= lng <= 180:
raise ConfigurationError("lat -90..90, lng -180..180 aralığında olmalıdır.")
if not 0 <= radius <= MAX_RADIUS_METERS:
raise ConfigurationError(f"radius 0..{int(MAX_RADIUS_METERS)} metre aralığında olmalıdır.")
return recipient_id, lat, lng, radius
class PointRegistrationProcessor:
"""Register one point alarm per CSV row, recording the outcome back into the CSV."""
def __init__(
self,
service: PointAlarmService,
*,
csv_path: Path,
webhook_config_path: Path,
filter_config_path: Path,
) -> None:
self.service = service
self.csv_path = csv_path.resolve()
self.webhook_config_path = webhook_config_path.resolve()
self.filter_config_path = filter_config_path.resolve()
def run(self) -> None:
rows, fieldnames = self._read_csv()
filters = load_point_alarm_filter(self.filter_config_path)
webhook_initialized = env_bool("IKLIM_WEBHOOK_INITIALIZED", False)
full_webhook = None if webhook_initialized else load_webhook_config(self.webhook_config_path)
account_id = self.service.get_account_id()
LOGGER.info("Kullanıcı hesabı doğrulandı; accountId API'den alındı.")
for row_number, row in enumerate(rows, start=2):
if str(row.get("status", "")).strip().upper() == "SUCCESS":
LOGGER.info("Satır %s zaten SUCCESS; atlandı.", row_number)
continue
try:
recipient_id, lat, lng, radius = parse_point_row(row)
except ConfigurationError as exc:
LOGGER.error("Satır %s gönderilmedi: %s", row_number, exc)
self._set_result(row, "FAILED", "NOT_SENT", "", str(exc))
self._write_csv(rows, fieldnames)
continue
payload = {
"recipientId": recipient_id,
"boundary": {"point": {"lat": lat, "lng": lng}, "radius": radius},
"webhook": {"accountId": account_id} if webhook_initialized else deepcopy(full_webhook),
"filter": deepcopy(filters),
}
registered = self._register(row_number, payload, row)
if registered and not webhook_initialized:
webhook_initialized = True
self.service.client.token_store.set_webhook_initialized(True)
self._write_csv(rows, fieldnames)
def _register(self, row_number: int, payload: dict[str, Any], row: dict[str, Any]) -> bool:
try:
response = self.service.register(payload, str(uuid4()))
except ApiError as exc:
recovered = self._try_recover(row_number, payload, exc)
if recovered:
self._set_result(row, "SUCCESS", f"HTTP_{exc.status_code or recovered[0]}", recovered[1], "")
LOGGER.info("Satır %s eşleşen kayıtla kurtarıldı: %s", row_number, recovered[1])
return True
self._set_result(
row,
"FAILED",
f"HTTP_{exc.status_code}" if exc.status_code else "NETWORK_ERROR",
"",
error_body_text(exc),
)
LOGGER.error("Satır %s başarısız: %s", row_number, exc)
return False
data = response.data if isinstance(response.data, dict) else {}
registration_id = as_uuid(data.get("registrationId"), version=4)
if not registration_id:
self._set_result(
row, "FAILED", f"HTTP_{response.status_code}", "",
"Yanıtta UUID v4 registrationId bulunamadı: " + json.dumps(response.data, ensure_ascii=False),
)
LOGGER.error("Satır %s yanıtında registrationId yok.", row_number)
return False
self._set_result(row, "SUCCESS", f"HTTP_{response.status_code}", registration_id, "")
LOGGER.info("Satır %s başarıyla kaydedildi: %s", row_number, registration_id)
for warning in response_warnings(response.data):
LOGGER.warning("Satır %s API uyarısı: %s", row_number, warning)
return True
def _try_recover(
self, row_number: int, payload: dict[str, Any], error: ApiError
) -> tuple[int, str] | None:
"""After an ambiguous failure, look for an identical registration (recipient + point + radius)."""
status = error.status_code
if status is not None and status != 409 and status not in RETRYABLE_STATUS_CODES:
return None
LOGGER.warning("Satır %s sonucu belirsiz; recipientId ile kontrol ediliyor.", row_number)
boundary = payload["boundary"]
try:
response = self.service.get_by_recipient_id(payload["recipientId"])
except ApiError as exc:
LOGGER.error("Satır %s için recipient sorgusu başarısız: %s", row_number, exc)
return None
data = response.data if isinstance(response.data, dict) else {}
for registration in data.get("registrations") or []:
flat = flatten_registration(registration) if isinstance(registration, dict) else {}
registration_id = as_uuid(flat.get("registrationId"), version=4)
if (
registration_id
and flat.get("recipientId") == payload["recipientId"]
and flat.get("lat") == boundary["point"]["lat"]
and flat.get("lng") == boundary["point"]["lng"]
and flat.get("radius") == boundary["radius"]
):
return response.status_code, registration_id
return None
def _read_csv(self) -> tuple[list[dict[str, Any]], list[str]]:
if not self.csv_path.is_file():
raise ConfigurationError(f"CSV dosyası bulunamadı: {self.csv_path}")
with self.csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle, delimiter=";")
if not reader.fieldnames:
raise ConfigurationError("CSV başlık satırı bulunamadı.")
missing = [name for name in REGISTER_REQUIRED_COLUMNS if name not in reader.fieldnames]
if missing:
raise ConfigurationError("CSV içinde eksik zorunlu sütunlar: " + ", ".join(missing))
fieldnames = [
name for name in reader.fieldnames if name not in REGISTER_RESULT_COLUMNS
] + REGISTER_RESULT_COLUMNS
rows = list(reader)
return rows, fieldnames
def _write_csv(self, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
write_csv_atomic(self.csv_path, rows, fieldnames)
@staticmethod
def _set_result(
row: dict[str, Any], status: str, http_status: str, registration_id: str, error_body: str
) -> None:
row["status"] = status
row["httpStatus"] = http_status
row["registrationId"] = registration_id
row["errorBody"] = error_body