feat: add nowcast point alarm update client with dry-run

Patches registrations from CSV rows, sending only non-blank cells, validating lat/lng pairing and ranges locally, warning about the speedThreshold PATCH gap (GAIA-476) and recording status, httpStatus and errorBody back into the CSV.
This commit is contained in:
2026-10-23 19:13:35 +03:00
parent ba249128a1
commit 8587669ea5
4 changed files with 290 additions and 0 deletions
+178
View File
@@ -765,3 +765,181 @@ class PointRegistrationProcessor:
row["httpStatus"] = http_status
row["registrationId"] = registration_id
row["errorBody"] = error_body
def _blank(value: Any) -> bool:
return value is None or str(value).strip() == ""
def _number(row: dict[str, Any], name: str) -> float | None:
if _blank(row.get(name)):
return None
try:
return float(str(row[name]).strip().replace(",", "."))
except ValueError as exc:
raise ConfigurationError(f"{name} sayı olmalıdır.") from exc
def _choice(row: dict[str, Any], name: str, allowed: set[str]) -> str | None:
if _blank(row.get(name)):
return None
value = str(row[name]).strip().upper()
if value not in allowed:
raise ConfigurationError(f"{name} şunlardan biri olmalıdır: {', '.join(sorted(allowed))}.")
return value
def _flag(row: dict[str, Any], name: str) -> bool | None:
if _blank(row.get(name)):
return None
value = str(row[name]).strip().lower()
if value in {"true", "1", "evet"}:
return True
if value in {"false", "0", "hayır", "hayir"}:
return False
raise ConfigurationError(f"{name} true veya false olmalıdır.")
def build_update_payload(row: dict[str, Any]) -> dict[str, Any]:
"""Build a PATCH body from a CSV row; blank cells are left out (null = untouched)."""
registration_id = as_uuid(str(row.get("registrationId", "")).strip(), version=4)
if not registration_id:
raise ConfigurationError("registrationId geçerli bir UUID olmalıdır.")
payload: dict[str, Any] = {"registrationId": registration_id}
recipient_id = str(row.get("recipientId") or "").strip()
if recipient_id:
if not recipient_id.isascii():
raise ConfigurationError("recipientId US-ASCII karakterlerinden oluşmalıdır.")
payload["recipientId"] = recipient_id
lat, lng, radius = _number(row, "lat"), _number(row, "lng"), _number(row, "radius")
if (lat is None) != (lng is None):
raise ConfigurationError("lat ve lng birlikte verilmelidir.")
boundary: dict[str, Any] = {}
if lat is not None:
if not -90 <= lat <= 90 or not -180 <= lng <= 180:
raise ConfigurationError("lat -90..90, lng -180..180 aralığında olmalıdır.")
boundary["point"] = {"lat": lat, "lng": lng}
if radius is not None:
if not 0 <= radius <= MAX_RADIUS_METERS:
raise ConfigurationError(f"radius 0..{int(MAX_RADIUS_METERS)} metre aralığında olmalıdır.")
boundary["radius"] = radius
if boundary:
payload["boundary"] = boundary
lightning = {
key: value
for key, value in {
"type": _choice(row, "lightningType", LIGHTNING_TYPES),
"peakCurrent": _number(row, "peakCurrent"),
"inCloudHeight": _number(row, "inCloudHeight"),
}.items()
if value is not None
}
thunderstorm = {
key: value
for key, value in {
"intersectsAffectedPolygon": _flag(row, "intersectsAffectedPolygon"),
"intersectsCellPolygon": _flag(row, "intersectsCellPolygon"),
"severityThreshold": _choice(row, "severityThreshold", SEVERITIES),
"speedThreshold": _number(row, "speedThreshold"),
}.items()
if value is not None
}
precipitation: dict[str, Any] = {}
if not _blank(row.get("precipitationIntensities")):
items = [part.strip().upper() for part in str(row["precipitationIntensities"]).split("|") if part.strip()]
invalid = [item for item in items if item not in INTENSITIES]
if invalid or not items:
raise ConfigurationError(
"precipitationIntensities '|' ile ayrılmış geçerli değerler içermelidir: "
+ ", ".join(sorted(INTENSITIES))
)
precipitation["intensities"] = items
filters = {
name: section
for name, section in (
("lightning", lightning),
("thunderstorm", thunderstorm),
("precipitation", precipitation),
)
if section
}
if filters:
payload["filter"] = filters
if len(payload) == 1:
raise ConfigurationError("Güncellenecek alan yok (registrationId dışında tüm hücreler boş).")
return payload
UPDATE_REQUIRED_COLUMNS = ["registrationId"]
UPDATE_COLUMNS = ["registrationId", "recipientId", "lat", "lng", "radius"] + FILTER_COLUMNS
class PointUpdateProcessor:
"""PATCH one registration per CSV row; blank cells are not sent."""
def __init__(self, service: PointAlarmService, *, csv_path: Path, dry_run: bool = False) -> None:
self.service = service
self.csv_path = csv_path.resolve()
self.dry_run = dry_run
def run(self) -> None:
rows, fieldnames = self._read_csv()
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:
payload = build_update_payload(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(rows, fieldnames)
continue
if payload.get("filter", {}).get("thunderstorm", {}).get("speedThreshold") is not None:
LOGGER.warning(
"Satır %s: thunderstorm.speedThreshold PATCH'te backend tarafından işlenmiyor "
"(GAIA-476); değer gönderilir ancak güncellenmeyebilir.",
row_number,
)
if self.dry_run:
print(f"[dry-run] satır {row_number}: PATCH {ALARMS_PATH}/update {json.dumps(payload, ensure_ascii=False)}")
continue
try:
response = self.service.update(payload, str(uuid4()))
except ApiError as exc:
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)
else:
self._set_result(row, "SUCCESS", f"HTTP_{response.status_code}", "")
LOGGER.info("Satır %s güncellendi: %s", row_number, payload["registrationId"])
for warning in response_warnings(response.data):
LOGGER.warning("Satır %s API uyarısı: %s", row_number, warning)
self._write(rows, fieldnames)
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 or "registrationId" not in reader.fieldnames:
raise ConfigurationError("CSV başlığında 'registrationId' sütunu bulunamadı.")
fieldnames = [n for n in reader.fieldnames if n not in ("status", "httpStatus", "errorBody")] + [
"status", "httpStatus", "errorBody",
]
return list(reader), fieldnames
def _write(self, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
if not self.dry_run:
write_csv_atomic(self.csv_path, rows, fieldnames)
@staticmethod
def _set_result(row: dict[str, Any], status: str, http_status: str, error_body: str) -> None:
row["status"] = status
row["httpStatus"] = http_status
row["errorBody"] = error_body