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