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:
+182
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user