Merge branch 'feature/point-alarm-clients'
This commit is contained in:
@@ -36,3 +36,12 @@ dist/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Point alarm client artifacts
|
||||||
|
*.db
|
||||||
|
nowcast-point-alarm-filter.json
|
||||||
|
nowcast-point-*-backup-*
|
||||||
|
point-alarm-registrations-*.csv
|
||||||
|
nowcast-point-registrations.csv
|
||||||
|
nowcast-point-update.csv
|
||||||
|
nowcast-point-alarm-records-*.csv
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ bu ortak yapıyı kullanarak noktalı virgüllü CSV satırlarını sırayla iş
|
|||||||
- `common/`: tekrar kullanılabilir API, HMAC, retry ve authentication katmanı
|
- `common/`: tekrar kullanılabilir API, HMAC, retry ve authentication katmanı
|
||||||
- `login.py`: manuel login komutu
|
- `login.py`: manuel login komutu
|
||||||
- `nowcast-geo-register.py`: CSV geo alarm registration istemcisi
|
- `nowcast-geo-register.py`: CSV geo alarm registration istemcisi
|
||||||
|
- `nowcast-point-page.py`, `nowcast-point-get.py`, `nowcast-point-register.py`, `nowcast-point-update.py`, `nowcast-point-unregister.py`: nokta alarm (`/v1/alarms/points`) istemcileri
|
||||||
|
- `nowcast-point-alarm-filter.example.json`, `nowcast-point-registrations.example.csv`, `nowcast-point-update.example.csv`: nokta alarm şablonları
|
||||||
- `.env.example`: güvenli ortam değişkeni şablonu
|
- `.env.example`: güvenli ortam değişkeni şablonu
|
||||||
- `webhook.example.json`: dört webhook authentication seçeneğini içeren şablon
|
- `webhook.example.json`: dört webhook authentication seçeneğini içeren şablon
|
||||||
- `nowcast-geo-alarm-filter.example.json`: ortak nowcast geo alarm filtreleri şablonu
|
- `nowcast-geo-alarm-filter.example.json`: ortak nowcast geo alarm filtreleri şablonu
|
||||||
@@ -151,3 +153,108 @@ python nowcast-geo-register.py nowcast-geo-alarm-registrations.csv \
|
|||||||
--nowcast-filter-config another-nowcast-geo-alarm-filter.json \
|
--nowcast-filter-config another-nowcast-geo-alarm-filter.json \
|
||||||
--debug-log logs/custom-debug.log
|
--debug-log logs/custom-debug.log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Nokta alarm (nowcast point) istemcileri
|
||||||
|
|
||||||
|
Nokta alarm istemcileri `BE-NowcastPointAlarm` servisinin `/v1/alarms/points` endpoint'lerini çağırır. Tüm komutlarda `--help` seçeneklerin tam dökümünü verir. Gerçek filtre değerleri için `nowcast-point-alarm-filter.example.json` dosyasını `nowcast-point-alarm-filter.json` olarak kopyalayın (git'e eklenmez). Webhook için mevcut `webhook.json` kullanılır, ancak geo alarmdan farklı olarak her register isteğinde `webhook.json` içindeki tam webhook (`deliveryPolicy` dahil) gönderilir; `IKLIM_WEBHOOK_INITIALIZED` ve `/v1/users/me` kullanılmaz. Backend nokta alarm kaydında webhook ve `deliveryPolicy` zorunlu tutar, hesabı kullanıcıdan kendisi bulur ve hesapta kayıtlı webhook varsa onu yeniden kullanır.
|
||||||
|
|
||||||
|
| Komut | Ne yapar |
|
||||||
|
| --- | --- |
|
||||||
|
| `nowcast-point-page.py` | Kayıtları sayfa sayfa listeler; SQLite'a (`iklim-sandbox.db`, `registrationId` anahtarlı) yazar, ekrana basar veya `--output` ile CSV/TXT üretir |
|
||||||
|
| `nowcast-point-get.py` | CSV/TXT içindeki `registrationId` (`--by id`) veya `recipientId` (`--by recipient`) değerlerini sorgular |
|
||||||
|
| `nowcast-point-register.py` | CSV satırlarından kayıt oluşturur |
|
||||||
|
| `nowcast-point-update.py` | CSV satırlarıyla kayıtları kısmen günceller (`--dry-run` destekler) |
|
||||||
|
| `nowcast-point-unregister.py` | Listedeki kayıtları siler (onay ve yedekle) |
|
||||||
|
|
||||||
|
### Önemli uyarılar
|
||||||
|
|
||||||
|
- **Page endpoint'inde sıralama (ORDER BY) yoktur.** Tüm sayfalar gezilirken kayıtların sırası garanti değildir; bu yüzden sayfa kayması yaşanabilir (bir kayıt iki kez gelebilir, bir kayıt hiç gelmeyebilir). Backend tarafında `createdAt` ardından `id` ile sıralama önerildi (yeni kayıtlar sona eklenir, `id` eşit zamanlarda sırayı sabitler).
|
||||||
|
- **Tüm kayıtlar çekilirken aynı anda register, update veya unregister yapmayın.** Veri değişirse sayfalar kayar. `nowcast-point-page.py` sonunda sunucunun bildirdiği `totalRecords` ile alınan benzersiz kayıt sayısını karşılaştırır ve fark varsa uyarı verir; bu durumda komutu yeniden çalıştırın. SQLite `registrationId` ile upsert yaptığı için tekrar çalıştırmak kayıt çoğaltmaz.
|
||||||
|
- `nowcast-point-page.py` varsayılan olarak `accountId` göndermez; sunucu listeyi çağıranın kendi hesabına daraltır ve normal kullanıcılar için doğru kullanım budur. `--account-id` yalnızca en az POWER_USER yetkisiyle verilebilir; aksi halde HTTP 403 döner ve betik `--account-id` vermemenizi söyleyen bir hata yazar. ADMIN kullanıcı `accountId` vermezse tüm hesapları görür.
|
||||||
|
- Backend her `RuntimeException`'ı HTTP 400 yapar; kayıt bulunamaması da 400 döner. Yalnızca gerçek 404 `NOT_FOUND` olarak işaretlenir.
|
||||||
|
|
||||||
|
### page
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 nowcast-point-page.py # tüm sayfalar, ekrana
|
||||||
|
python3 nowcast-point-page.py --output point-alarm-registrations.csv # point-alarm-registrations-YYYYmmdd-HHMMSS.csv
|
||||||
|
python3 nowcast-point-page.py --page-number 2 --page-size 50 # yalnızca tek sayfa
|
||||||
|
python3 nowcast-point-page.py --account-id 2f04f1b5-1c0a-4c4e-b0a7-0ba6e9b7f2e1 # yalnızca POWER_USER ve üstü
|
||||||
|
python3 nowcast-point-page.py --recipient-id musteri-1 --recipient-id musteri-2
|
||||||
|
python3 nowcast-point-page.py --polygon "32.8,39.9;32.9,39.9;32.9,40.0;32.8,39.9"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Poligon `lng,lat` çiftlerinden oluşan **düz halkadır**, en az 4 nokta ve kapalı olmalıdır. Filtre, kayıt noktasının poligonla kesişimine bakar; yarıçap yok sayılır.
|
||||||
|
- Çıktıda webhook bilgisi dosya başında bir kez yer alır (CSV'de `#webhook` satırı); `password`, `jwtToken`, `apiKey` ve `secret` alanları `***` ile maskelenir. Her filtre ayrı bir sütundur. API yanıtında `createdAt`/`updatedAt` bulunmadığı için çıktıda da yoktur.
|
||||||
|
- Etkileşimli terminalde ve `--output` verilmediğinde sonunda "SQLite verisi CSV/TXT dosyasına aktarılsın mı?" sorulur.
|
||||||
|
|
||||||
|
### get
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 nowcast-point-get.py idler.txt --output sonuc.csv # registrationId listesi (TXT), CSV çıktı
|
||||||
|
python3 nowcast-point-get.py idler.csv --output sonuc.txt # registrationId sütunlu CSV, JSON Lines çıktı
|
||||||
|
python3 nowcast-point-get.py alicilar.txt --by recipient --output sonuc.csv # recipientId ile sorgu
|
||||||
|
```
|
||||||
|
|
||||||
|
TXT girdi örneği (`idler.txt`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
# bu satır atlanır
|
||||||
|
f7587d9e-2481-4b4c-818d-c8d1946851b7
|
||||||
|
57d1e2ba-bf2d-4ef3-b078-523060b2000c
|
||||||
|
```
|
||||||
|
|
||||||
|
Girdi CSV ise başlıkta `registrationId` (veya `recipientId`) sütunu aranır; TXT ise satır başına bir ID yazılır, boş satırlar ve `#` ile başlayanlar atlanır. `--output` zorunludur; uzantı `.csv` ise noktalı virgüllü CSV, `.txt` ise JSON Lines yazılır. Satır durumları `SUCCESS`, `NOT_FOUND` (HTTP 404) ve `FAILED`'dır (`httpStatus` ve `errorBody` dolar). Bir `recipientId` birden çok kayıt dönerse her kayıt ayrı satırdır.
|
||||||
|
|
||||||
|
### register
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp nowcast-point-alarm-filter.example.json nowcast-point-alarm-filter.json # filtreleri düzenleyin
|
||||||
|
python3 nowcast-point-register.py nowcast-point-registrations.example.csv
|
||||||
|
python3 nowcast-point-register.py kayitlar.csv --filter-config baska-filtre.json --webhook-config baska-webhook.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Girdi CSV örneği (sonuç sütunları işlem sonunda aynı dosyaya eklenir):
|
||||||
|
|
||||||
|
```csv
|
||||||
|
recipientId;lat;lng;radius
|
||||||
|
musteri-001;39.9208;32.8541;1000
|
||||||
|
musteri-002;41.0082;28.9784;5000
|
||||||
|
```
|
||||||
|
|
||||||
|
CSV sütunları yalnızca `recipientId;lat;lng;radius` değerleridir (`radius` metre, 0-50000). Filtre CSV'de tekrarlanmaz; ortak `nowcast-point-alarm-filter.json` dosyasından okunur ve her kayda uygulanır (`--filter-config` ile değiştirilir). Sonuç sütunları aynı dosyaya yazılır: `status;httpStatus;registrationId;errorBody`. HTTP 200 dışındaki yanıtlarda cevap gövdesi `errorBody` içine yazılır. `SUCCESS` satırlar yeniden çalıştırmada atlanır. Sonucu belirsiz kalan istek yalnızca `recipientId`, nokta ve yarıçap birebir eşleşirse mevcut kayıtla kurtarılır. Aynı hesap, `recipientId`, nokta ve yarıçap ikinci kez kaydedilemez (400).
|
||||||
|
|
||||||
|
### update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 nowcast-point-update.py guncelle.csv --dry-run # gövdeleri gösterir, istek göndermez
|
||||||
|
python3 nowcast-point-update.py guncelle.csv # gerçekten günceller
|
||||||
|
```
|
||||||
|
|
||||||
|
Girdi CSV örneği (boş hücre gönderilmez):
|
||||||
|
|
||||||
|
```csv
|
||||||
|
registrationId;recipientId;lat;lng;radius;lightningType;peakCurrent;inCloudHeight;intersectsAffectedPolygon;intersectsCellPolygon;severityThreshold;speedThreshold;precipitationIntensities
|
||||||
|
f7587d9e-2481-4b4c-818d-c8d1946851b7;;;;2500;;;;;;HIGH;;LIGHT|MODERATE
|
||||||
|
57d1e2ba-bf2d-4ef3-b078-523060b2000c;;40.0;33.0;;PULSE_IN_CLOUD;;;;true;;;
|
||||||
|
```
|
||||||
|
|
||||||
|
CSV'de `registrationId` zorunludur; `recipientId;lat;lng;radius` ve filtre sütunları (`lightningType;peakCurrent;inCloudHeight;intersectsAffectedPolygon;intersectsCellPolygon;severityThreshold;speedThreshold;precipitationIntensities`, liste `|` ile ayrılır) isteğe bağlıdır. **Boş hücre gönderilmez**; `lat` ve `lng` birlikte verilmelidir. Önce `--dry-run` ile gövdeleri kontrol edin.
|
||||||
|
|
||||||
|
- Backend PATCH'i bir alanı **temizleyemez** (null = dokunma).
|
||||||
|
- `thunderstorm.speedThreshold` backend PATCH'inde işlenmiyor, değer gönderilse de güncellenmeyebilir (GAIA-476).
|
||||||
|
- Backend sınır güncellemesinde doğrulama yapmaz; yarıçap ve koordinat aralıkları istemcide yerelde doğrulanır.
|
||||||
|
- Webhook güncellemesi bu sürümde desteklenmez.
|
||||||
|
|
||||||
|
### unregister
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 nowcast-point-unregister.py silinecekler.txt # yedek alır, SİL onayı ister
|
||||||
|
python3 nowcast-point-unregister.py silinecekler.csv --output sonuc.csv # sonuçları dosyaya da yazar
|
||||||
|
python3 nowcast-point-unregister.py silinecekler.txt --backup yedek/point-yedek.csv # yedek yolu
|
||||||
|
python3 nowcast-point-unregister.py silinecekler.txt --no-backup # yedeksiz (önerilmez)
|
||||||
|
```
|
||||||
|
|
||||||
|
Girdi `registrationId` içeren bir CSV veya satır başına bir ID içeren TXT olabilir.
|
||||||
|
|
||||||
|
Başlamadan önce ortam adı ve URL, silinecek kayıt sayısı ve "geri alınamaz" uyarısı gösterilir; bir hesabın **son kaydı** silinirse webhook tanımı da silinir. Onay için `SİL` yazılmalıdır (`--yes` yoktur). `prod` ortamında ek olarak ortam adı (`prod`) yazdırılır. Silmeden önce her kayıt `get-by-id` ile yedeklenir (`--backup`, kapatmak için `--no-backup`); yedek yazılamazsa hiçbir kayıt silinmez.
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class Settings:
|
|||||||
token_expiry_skew_seconds: int
|
token_expiry_skew_seconds: int
|
||||||
max_retries: int
|
max_retries: int
|
||||||
retry_base_seconds: float
|
retry_base_seconds: float
|
||||||
|
environment: str = ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, env_file: Path = DEFAULT_ENV_FILE) -> "Settings":
|
def from_env(cls, env_file: Path = DEFAULT_ENV_FILE) -> "Settings":
|
||||||
@@ -96,6 +97,7 @@ class Settings:
|
|||||||
return cls(
|
return cls(
|
||||||
env_file=env_file,
|
env_file=env_file,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
|
environment=environment,
|
||||||
hmac_secret=_required("IKLIM_HMAC_SECRET"),
|
hmac_secret=_required("IKLIM_HMAC_SECRET"),
|
||||||
accept_language=language,
|
accept_language=language,
|
||||||
timeout_seconds=_positive_float("IKLIM_TIMEOUT_SECONDS", "30"),
|
timeout_seconds=_positive_float("IKLIM_TIMEOUT_SECONDS", "30"),
|
||||||
|
|||||||
+18
-151
@@ -3,31 +3,30 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote, urlsplit
|
from urllib.parse import quote
|
||||||
from uuid import UUID, uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from common import ApiError, ConfigurationError, IklimClient
|
from common import ApiError, ConfigurationError, IklimClient
|
||||||
from common.config import env_bool
|
from common.config import env_bool
|
||||||
from common.client import RETRYABLE_STATUS_CODES
|
from common.client import RETRYABLE_STATUS_CODES
|
||||||
|
from nowcast_common import (
|
||||||
|
read_json_object,
|
||||||
|
response_warnings,
|
||||||
|
as_uuid,
|
||||||
|
fetch_account_id,
|
||||||
|
load_webhook_config,
|
||||||
|
write_csv_atomic,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
RESULT_COLUMNS = ["status", "httpStatus", "registrationId"]
|
RESULT_COLUMNS = ["status", "httpStatus", "registrationId"]
|
||||||
REQUIRED_CSV_COLUMNS = ["city", "district", "recipientId"]
|
REQUIRED_CSV_COLUMNS = ["city", "district", "recipientId"]
|
||||||
AUTHENTICATION_FIELDS = {
|
|
||||||
"BASIC": ("username", "password"),
|
|
||||||
"JWT_TOKEN": ("jwtToken",),
|
|
||||||
"API_KEY": ("apiKey", "asQueryParameter", "asHeader"),
|
|
||||||
"HMAC_SIGNATURE": ("clientId", "secret", "algorithm"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_turkish_name(value: str) -> str:
|
def normalize_turkish_name(value: str) -> str:
|
||||||
@@ -35,83 +34,8 @@ def normalize_turkish_name(value: str) -> str:
|
|||||||
return trimmed.translate(str.maketrans({"I": "ı", "İ": "i"})).casefold()
|
return trimmed.translate(str.maketrans({"I": "ı", "İ": "i"})).casefold()
|
||||||
|
|
||||||
|
|
||||||
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 load_nowcast_geo_alarm_filter(path: Path) -> dict[str, Any]:
|
def load_nowcast_geo_alarm_filter(path: Path) -> dict[str, Any]:
|
||||||
config = _read_json_object(path, "Nowcast geo alarm filter config")
|
config = read_json_object(path, "Nowcast geo alarm filter config")
|
||||||
if not config:
|
if not config:
|
||||||
raise ConfigurationError("Nowcast geo alarm filter config boş olamaz.")
|
raise ConfigurationError("Nowcast geo alarm filter config boş olamaz.")
|
||||||
|
|
||||||
@@ -158,44 +82,12 @@ def load_nowcast_geo_alarm_filter(path: Path) -> dict[str, Any]:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _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
|
|
||||||
|
|
||||||
|
|
||||||
class GeoAlarmService:
|
class GeoAlarmService:
|
||||||
def __init__(self, client: IklimClient) -> None:
|
def __init__(self, client: IklimClient) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
|
|
||||||
def get_account_id(self) -> str:
|
def get_account_id(self) -> str:
|
||||||
response = self.client.request_json("GET", "/v1/users/me")
|
return fetch_account_id(self.client)
|
||||||
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 _uuid(account_id):
|
|
||||||
raise ApiError("/v1/users/me yanıtında geçerli account.id bulunamadı.")
|
|
||||||
return account_id
|
|
||||||
|
|
||||||
def list_cities(self) -> list[dict[str, Any]]:
|
def list_cities(self) -> list[dict[str, Any]]:
|
||||||
data = self.client.request_json(
|
data = self.client.request_json(
|
||||||
@@ -227,14 +119,14 @@ class GeoAlarmService:
|
|||||||
if isinstance(response.data, dict)
|
if isinstance(response.data, dict)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
registration_id = _uuid(registration_id, version=4)
|
registration_id = as_uuid(registration_id, version=4)
|
||||||
if not registration_id:
|
if not registration_id:
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
"Başarılı registration yanıtında UUID v4 registrationId bulunamadı.",
|
"Başarılı registration yanıtında UUID v4 registrationId bulunamadı.",
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
response_data=response.data,
|
response_data=response.data,
|
||||||
)
|
)
|
||||||
return response.status_code, registration_id, _response_warnings(response.data)
|
return response.status_code, registration_id, response_warnings(response.data)
|
||||||
|
|
||||||
def find_by_recipient_id(
|
def find_by_recipient_id(
|
||||||
self, recipient_id: str
|
self, recipient_id: str
|
||||||
@@ -260,14 +152,14 @@ class GeoAlarmService:
|
|||||||
continue
|
continue
|
||||||
if registration.get("recipientId") != recipient_id:
|
if registration.get("recipientId") != recipient_id:
|
||||||
continue
|
continue
|
||||||
registration_id = _uuid(registration.get("registrationId"), version=4)
|
registration_id = as_uuid(registration.get("registrationId"), version=4)
|
||||||
if registration_id:
|
if registration_id:
|
||||||
return (
|
return (
|
||||||
response.status_code,
|
response.status_code,
|
||||||
registration_id,
|
registration_id,
|
||||||
_response_warnings(response.data, registration),
|
response_warnings(response.data, registration),
|
||||||
)
|
)
|
||||||
return response.status_code, None, _response_warnings(response.data)
|
return response.status_code, None, response_warnings(response.data)
|
||||||
|
|
||||||
|
|
||||||
def _name_index(items: list[dict[str, Any]], item_type: str) -> dict[str, int]:
|
def _name_index(items: list[dict[str, Any]], item_type: str) -> dict[str, int]:
|
||||||
@@ -461,32 +353,7 @@ class CsvRegistrationProcessor:
|
|||||||
return rows, fieldnames
|
return rows, fieldnames
|
||||||
|
|
||||||
def _write_csv(self, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
|
def _write_csv(self, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
|
||||||
temp_path: Path | None = None
|
write_csv_atomic(self.csv_path, rows, fieldnames)
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
"w",
|
|
||||||
encoding="utf-8-sig",
|
|
||||||
newline="",
|
|
||||||
dir=self.csv_path.parent,
|
|
||||||
prefix=f".{self.csv_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, self.csv_path)
|
|
||||||
finally:
|
|
||||||
if temp_path and temp_path.exists():
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _set_result(
|
def _set_result(
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"lightning": {
|
||||||
|
"type": "FLASH_CLOUD_TO_GROUND",
|
||||||
|
"peakCurrent": -12.5,
|
||||||
|
"inCloudHeight": 4500
|
||||||
|
},
|
||||||
|
"thunderstorm": {
|
||||||
|
"intersectsAffectedPolygon": true,
|
||||||
|
"intersectsCellPolygon": false,
|
||||||
|
"severityThreshold": "MEDIUM",
|
||||||
|
"speedThreshold": 20
|
||||||
|
},
|
||||||
|
"precipitation": {
|
||||||
|
"intensities": ["LIGHT", "MODERATE"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Look up point alarm registrations by registrationId or recipientId from a CSV/TXT list."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common import ConfigurationError, IklimClient, configure_logging
|
||||||
|
from common.errors import ApiError
|
||||||
|
from point_alarm import (
|
||||||
|
RESULT_COLUMNS,
|
||||||
|
PointAlarmService,
|
||||||
|
PointStore,
|
||||||
|
lookup_rows,
|
||||||
|
read_id_list,
|
||||||
|
timestamped_path,
|
||||||
|
write_records,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Nokta alarm kayıtlarını get-by-id veya get-by-recipient-id ile sorgular. Girdi CSV "
|
||||||
|
"(başlıkta registrationId/recipientId sütunu, ';' ayraçlı) veya TXT (satır başına bir ID; "
|
||||||
|
"boş ve '#' ile başlayan satırlar atlanır) olabilir. Sonuçlar SQLite'a da yazılır."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("input_file", type=Path, help="ID listesi (.csv veya .txt)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--by",
|
||||||
|
choices=["id", "recipient"],
|
||||||
|
default="id",
|
||||||
|
help="Sorgu türü: id=registrationId, recipient=recipientId (varsayılan: id)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help=(
|
||||||
|
"Çıktı dosyası: .csv (noktalı virgüllü) veya .txt (JSON Lines); dosya adına zaman soneki "
|
||||||
|
"eklenir. Satır durumları: SUCCESS, NOT_FOUND (HTTP 404), FAILED (httpStatus+errorBody). "
|
||||||
|
"Bir recipientId birden çok kayıt dönerse her kayıt ayrı satırdır"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--db", type=Path, default=BASE_DIR / "iklim-sandbox.db", help="SQLite dosyası"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug-log",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "logs" / "iklim-api-debug.log",
|
||||||
|
help="Maskelenmiş HTTP debug log dosyası",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
configure_logging(args.debug_log)
|
||||||
|
store = None
|
||||||
|
try:
|
||||||
|
column = "registrationId" if args.by == "id" else "recipientId"
|
||||||
|
queries = read_id_list(args.input_file, column)
|
||||||
|
client = IklimClient.from_env(BASE_DIR / ".env")
|
||||||
|
service = PointAlarmService(client)
|
||||||
|
store = PointStore(args.db)
|
||||||
|
rows, webhooks = lookup_rows(service, store, queries, args.by, None)
|
||||||
|
path = timestamped_path(args.output)
|
||||||
|
write_records(path, rows, webhooks, columns=RESULT_COLUMNS)
|
||||||
|
failed = sum(1 for row in rows if row["status"] == "FAILED")
|
||||||
|
print(f"{len(queries)} sorgu, {len(rows)} satır yazıldı: {path.resolve()}")
|
||||||
|
return 1 if failed else 0
|
||||||
|
except (ConfigurationError, ApiError, OSError) as exc:
|
||||||
|
logging.error("İşlem durduruldu: %s", exc)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if store:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""List point alarm registrations page by page into SQLite, screen or CSV/TXT."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common import ApiError, ConfigurationError, IklimClient, configure_logging
|
||||||
|
from point_alarm import (
|
||||||
|
MAX_PAGE_SIZE,
|
||||||
|
PointAlarmService,
|
||||||
|
PointStore,
|
||||||
|
fetch_pages,
|
||||||
|
parse_polygon,
|
||||||
|
print_records,
|
||||||
|
prompt_export,
|
||||||
|
timestamped_path,
|
||||||
|
write_records,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"iklim.co nokta alarm kayıtlarını /v1/alarms/points/page ile listeler. "
|
||||||
|
"Sonuçlar SQLite'a (registrationId anahtarlı) yazılır; varsayılan olarak ekrana "
|
||||||
|
"basılır, --output ile CSV/TXT'ye yazılır."
|
||||||
|
),
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--account-id",
|
||||||
|
help=(
|
||||||
|
"Sorgulanacak hesap ID'si; yalnızca en az POWER_USER yetkisiyle verilebilir (aksi halde "
|
||||||
|
"HTTP 403). Verilmezse sunucu kendi hesabınıza daraltır; normal kullanıcılar vermemelidir"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--recipient-id",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Alıcı ID'sine göre süz; birden fazla kez verilebilir",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--polygon",
|
||||||
|
help="Poligon kesişim filtresi: 'lng,lat;lng,lat;...' (en az 4 nokta, kapalı halka)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--page-number",
|
||||||
|
type=int,
|
||||||
|
help="Yalnızca bu sayfayı getir (0'dan başlar); verilmezse tüm sayfalar gezilir",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--page-size",
|
||||||
|
type=int,
|
||||||
|
default=MAX_PAGE_SIZE,
|
||||||
|
help=f"Sayfa boyutu, 1-{MAX_PAGE_SIZE} (varsayılan: {MAX_PAGE_SIZE})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
help=(
|
||||||
|
"Çıktı dosyası (.csv noktalı virgüllü, .txt JSON Lines); dosya adına "
|
||||||
|
"zaman soneki eklenir (dosya-YYYYmmdd-HHMMSS.csv). Verilmezse ekrana yazılır"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--db",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "iklim-sandbox.db",
|
||||||
|
help="SQLite dosyası (varsayılan: iklim-sandbox.db)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug-log",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "logs" / "iklim-api-debug.log",
|
||||||
|
help="Maskelenmiş HTTP debug log dosyası",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not 1 <= args.page_size <= MAX_PAGE_SIZE:
|
||||||
|
parser.error(f"--page-size 1-{MAX_PAGE_SIZE} aralığında olmalıdır")
|
||||||
|
if args.page_number is not None and args.page_number < 0:
|
||||||
|
parser.error("--page-number 0 veya daha büyük olmalıdır")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
configure_logging(args.debug_log)
|
||||||
|
store = None
|
||||||
|
try:
|
||||||
|
polygon = parse_polygon(args.polygon) if args.polygon else None
|
||||||
|
client = IklimClient.from_env(BASE_DIR / ".env")
|
||||||
|
service = PointAlarmService(client)
|
||||||
|
store = PointStore(args.db)
|
||||||
|
summary = fetch_pages(
|
||||||
|
service,
|
||||||
|
store,
|
||||||
|
account_id=args.account_id,
|
||||||
|
recipient_ids=args.recipient_id or None,
|
||||||
|
polygon=polygon,
|
||||||
|
page_size=args.page_size,
|
||||||
|
page_number=args.page_number,
|
||||||
|
)
|
||||||
|
records = store.records(None, summary.registration_ids)
|
||||||
|
webhooks = store.webhooks_of(records)
|
||||||
|
if args.output:
|
||||||
|
path = timestamped_path(args.output)
|
||||||
|
write_records(path, records, webhooks)
|
||||||
|
print(f"{len(records)} kayıt yazıldı: {path.resolve()}")
|
||||||
|
else:
|
||||||
|
print_records(records, webhooks)
|
||||||
|
if args.page_number is None and summary.missing:
|
||||||
|
logging.warning(
|
||||||
|
"Sunucu %s kayıt bildirdi, %s benzersiz kayıt alındı (%s eksik). Sayfalama sırasında "
|
||||||
|
"kayıt eklenmiş/silinmiş olabilir; komutu yeniden çalıştırın.",
|
||||||
|
summary.total_records,
|
||||||
|
summary.distinct,
|
||||||
|
summary.missing,
|
||||||
|
)
|
||||||
|
if summary.duplicates:
|
||||||
|
logging.warning("Sayfalar arasında %s tekrar eden kayıt görüldü.", summary.duplicates)
|
||||||
|
if not args.output:
|
||||||
|
prompt_export(store, args.account_id)
|
||||||
|
except (ConfigurationError, ApiError, OSError) as exc:
|
||||||
|
logging.error("İşlem durduruldu: %s", exc)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if store:
|
||||||
|
store.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create point alarm registrations for every eligible row in a CSV file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common import ApiError, ConfigurationError, IklimClient, configure_logging
|
||||||
|
from point_alarm import PointAlarmService, PointRegistrationProcessor
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"CSV satırlarından iklim.co nokta alarm kayıtları oluşturur. CSV sütunları: "
|
||||||
|
"recipientId;lat;lng;radius (radius metre, 0-50000). Filtre ortak JSON dosyasından "
|
||||||
|
"okunur. Sonuç sütunları (status;httpStatus;registrationId;errorBody) aynı dosyaya "
|
||||||
|
"yazılır; SUCCESS satırlar yeniden çalıştırmada atlanır."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("csv_file", type=Path, help="İşlenecek noktalı virgüllü CSV")
|
||||||
|
parser.add_argument(
|
||||||
|
"--webhook-config",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "webhook.json",
|
||||||
|
help="Webhook JSON dosyası (varsayılan: webhook.json)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--filter-config",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "nowcast-point-alarm-filter.json",
|
||||||
|
help="Her kayda uygulanan filtre JSON dosyası (varsayılan: nowcast-point-alarm-filter.json)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug-log",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "logs" / "iklim-api-debug.log",
|
||||||
|
help="Maskelenmiş HTTP debug log dosyası",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
configure_logging(args.debug_log)
|
||||||
|
try:
|
||||||
|
client = IklimClient.from_env(BASE_DIR / ".env")
|
||||||
|
PointRegistrationProcessor(
|
||||||
|
PointAlarmService(client),
|
||||||
|
csv_path=args.csv_file,
|
||||||
|
webhook_config_path=args.webhook_config,
|
||||||
|
filter_config_path=args.filter_config,
|
||||||
|
).run()
|
||||||
|
except (ConfigurationError, ApiError, OSError) as exc:
|
||||||
|
logging.error("İşlem durduruldu: %s", exc)
|
||||||
|
return 1
|
||||||
|
logging.info("CSV işlemi tamamlandı: %s", args.csv_file.resolve())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
recipientId;lat;lng;radius
|
||||||
|
recipient-001;39.9208;32.8541;1000
|
||||||
|
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Delete point alarm registrations listed in a CSV/TXT file, with confirmation and backup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common import ApiError, ConfigurationError, IklimClient, configure_logging
|
||||||
|
from point_alarm import (
|
||||||
|
PointAlarmService,
|
||||||
|
PointUnregisterProcessor,
|
||||||
|
read_id_list,
|
||||||
|
timestamped_path,
|
||||||
|
write_records,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Dosyadaki registrationId'leri siler (DELETE /v1/alarms/points/unregister/{id}). "
|
||||||
|
"Girdi CSV (registrationId sütunu) veya TXT (satır başına bir ID). Başlamadan önce "
|
||||||
|
"ortam, silinecek sayı ve 'geri alınamaz' uyarısı gösterilir; onay için SİL yazılmalıdır "
|
||||||
|
"(prod ortamında ek olarak 'prod' yazılır). Bir hesabın son kaydı silinirse webhook da "
|
||||||
|
"silinir. Silmeden önce her kayıt get-by-id ile yedeklenir; yedek yazılamazsa hiçbir "
|
||||||
|
"kayıt silinmez."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("input_file", type=Path, help="ID listesi (.csv veya .txt)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--backup",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "nowcast-point-unregister-backup.csv",
|
||||||
|
help="Yedek dosyası (.csv/.txt, zaman soneki eklenir; varsayılan: nowcast-point-unregister-backup.csv)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--no-backup", action="store_true", help="Silmeden önce yedek almayı kapatır")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
help="Sonuç dosyası (.csv/.txt, zaman soneki eklenir); verilmezse yalnızca özet yazılır",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug-log",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "logs" / "iklim-api-debug.log",
|
||||||
|
help="Maskelenmiş HTTP debug log dosyası",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
configure_logging(args.debug_log)
|
||||||
|
try:
|
||||||
|
ids = read_id_list(args.input_file, "registrationId")
|
||||||
|
client = IklimClient.from_env(BASE_DIR / ".env")
|
||||||
|
processor = PointUnregisterProcessor(
|
||||||
|
PointAlarmService(client),
|
||||||
|
ids=ids,
|
||||||
|
environment=client.settings.environment,
|
||||||
|
base_url=client.settings.base_url,
|
||||||
|
backup_path=None if args.no_backup else timestamped_path(args.backup),
|
||||||
|
)
|
||||||
|
results = processor.run()
|
||||||
|
if not results:
|
||||||
|
return 1
|
||||||
|
failed = sum(1 for row in results if row["status"] == "FAILED")
|
||||||
|
print(f"{len(results) - failed} silindi, {failed} başarısız.")
|
||||||
|
if args.output:
|
||||||
|
path = timestamped_path(args.output)
|
||||||
|
write_records(path, results, [], columns=["registrationId", "status", "httpStatus", "errorBody"])
|
||||||
|
print(f"Sonuçlar yazıldı: {path.resolve()}")
|
||||||
|
return 1 if failed else 0
|
||||||
|
except (ConfigurationError, ApiError, OSError) as exc:
|
||||||
|
logging.error("İşlem durduruldu: %s", exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
registrationId;recipientId;lat;lng;radius;lightningType;peakCurrent;inCloudHeight;intersectsAffectedPolygon;intersectsCellPolygon;severityThreshold;speedThreshold;precipitationIntensities
|
||||||
|
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Patch point alarm registrations from a CSV file (blank cells are not sent)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from common import ApiError, ConfigurationError, IklimClient, configure_logging
|
||||||
|
from point_alarm import PointAlarmService, PointUpdateProcessor
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"CSV satırlarından nokta alarm kayıtlarını kısmen günceller (PATCH). Sütunlar: "
|
||||||
|
"registrationId (zorunlu) ve isteğe bağlı recipientId;lat;lng;radius;lightningType;"
|
||||||
|
"peakCurrent;inCloudHeight;intersectsAffectedPolygon;intersectsCellPolygon;"
|
||||||
|
"severityThreshold;speedThreshold;precipitationIntensities ('|' ile ayrılır). Boş hücre "
|
||||||
|
"gönderilmez; lat ve lng birlikte verilmelidir. Bir alan boşaltılamaz (temizleme "
|
||||||
|
"desteklenmez). thunderstorm.speedThreshold backend PATCH'inde işlenmiyor (GAIA-476). "
|
||||||
|
"Sonuç sütunları status;httpStatus;errorBody aynı dosyaya yazılır."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("csv_file", type=Path, help="İşlenecek noktalı virgüllü CSV")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="İstek göndermeden ve CSV'yi değiştirmeden PATCH gövdelerini ekrana yazar",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug-log",
|
||||||
|
type=Path,
|
||||||
|
default=BASE_DIR / "logs" / "iklim-api-debug.log",
|
||||||
|
help="Maskelenmiş HTTP debug log dosyası",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
configure_logging(args.debug_log)
|
||||||
|
try:
|
||||||
|
client = IklimClient.from_env(BASE_DIR / ".env")
|
||||||
|
PointUpdateProcessor(
|
||||||
|
PointAlarmService(client), csv_path=args.csv_file, dry_run=args.dry_run
|
||||||
|
).run()
|
||||||
|
except (ConfigurationError, ApiError, OSError) as exc:
|
||||||
|
logging.error("İşlem durduruldu: %s", exc)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""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
|
||||||
+1017
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,345 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from common.security import TokenStore
|
||||||
|
|
||||||
|
from common import ApiError, ConfigurationError
|
||||||
|
from common.client import ApiResponse
|
||||||
|
from point_alarm import (
|
||||||
|
PointRegistrationProcessor,
|
||||||
|
PointUnregisterProcessor,
|
||||||
|
PointUpdateProcessor,
|
||||||
|
build_update_payload,
|
||||||
|
PointStore,
|
||||||
|
fetch_pages,
|
||||||
|
flatten_registration,
|
||||||
|
load_point_alarm_filter,
|
||||||
|
lookup_rows,
|
||||||
|
read_id_list,
|
||||||
|
mask_secrets,
|
||||||
|
parse_polygon,
|
||||||
|
timestamped_path,
|
||||||
|
write_records,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def registration(index: int, recipient: str = "r1") -> dict:
|
||||||
|
return {
|
||||||
|
"registrationId": f"id-{index}",
|
||||||
|
"recipientId": recipient,
|
||||||
|
"boundary": {"point": {"lat": 39.9, "lng": 32.8}, "radius": 1000},
|
||||||
|
"filter": {
|
||||||
|
"lightning": {"type": "PULSE_IN_CLOUD", "peakCurrent": 5},
|
||||||
|
"thunderstorm": {"intersectsCellPolygon": True, "severityThreshold": "LOW"},
|
||||||
|
"precipitation": {"intensities": ["LIGHT", "HEAVY"]},
|
||||||
|
},
|
||||||
|
"webhook": {"accountId": "acc", "url": "https://x", "authentication": {"password": "p"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeService:
|
||||||
|
def __init__(self, registrations: list[dict], forbid_account: bool = False) -> None:
|
||||||
|
self.registrations = registrations
|
||||||
|
self.forbid_account = forbid_account
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
|
def page(self, *, page_number, page_size, account_id=None, **_):
|
||||||
|
self.calls.append({"page": page_number, "account": account_id})
|
||||||
|
if self.forbid_account and account_id:
|
||||||
|
raise ApiError("forbidden", status_code=403)
|
||||||
|
start = page_number * page_size
|
||||||
|
return {
|
||||||
|
"registrations": self.registrations[start : start + page_size],
|
||||||
|
"totalRecords": len(self.registrations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PointAlarmTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.dir = Path(self.tmp.name)
|
||||||
|
self.store = PointStore(self.dir / "t.db")
|
||||||
|
self.addCleanup(self.store.close)
|
||||||
|
|
||||||
|
def test_flatten(self) -> None:
|
||||||
|
flat = flatten_registration(registration(1))
|
||||||
|
self.assertEqual(flat["precipitationIntensities"], "LIGHT|HEAVY")
|
||||||
|
self.assertEqual(flat["intersectsCellPolygon"], "true")
|
||||||
|
self.assertIsNone(flat["speedThreshold"])
|
||||||
|
|
||||||
|
def test_mask(self) -> None:
|
||||||
|
masked = mask_secrets(registration(1)["webhook"])
|
||||||
|
self.assertEqual(masked["authentication"]["password"], "***")
|
||||||
|
self.assertEqual(masked["url"], "https://x")
|
||||||
|
|
||||||
|
def test_fetch_all_pages_upserts_without_duplicates(self) -> None:
|
||||||
|
service = FakeService([registration(i) for i in range(5)])
|
||||||
|
summary = fetch_pages(service, self.store, account_id="acc", page_size=2)
|
||||||
|
self.assertEqual(summary.distinct, 5)
|
||||||
|
self.assertEqual(summary.missing, 0)
|
||||||
|
fetch_pages(service, self.store, account_id="acc", page_size=2)
|
||||||
|
self.assertEqual(self.store.count(), 5)
|
||||||
|
self.assertEqual(self.store.webhooks("acc")[0]["authentication"]["password"], "***")
|
||||||
|
|
||||||
|
def test_single_page(self) -> None:
|
||||||
|
service = FakeService([registration(i) for i in range(5)])
|
||||||
|
summary = fetch_pages(service, self.store, account_id="acc", page_size=2, page_number=1)
|
||||||
|
self.assertEqual(summary.received, 2)
|
||||||
|
self.assertEqual(len(service.calls), 1)
|
||||||
|
|
||||||
|
def test_forbidden_account_gives_clear_error(self) -> None:
|
||||||
|
service = FakeService([registration(1)], forbid_account=True)
|
||||||
|
with self.assertRaisesRegex(ConfigurationError, "--account-id vermeyin"):
|
||||||
|
fetch_pages(service, self.store, account_id="acc")
|
||||||
|
summary = fetch_pages(service, self.store, account_id=None)
|
||||||
|
self.assertEqual(summary.distinct, 1)
|
||||||
|
|
||||||
|
def test_csv_and_txt_output(self) -> None:
|
||||||
|
fetch_pages(FakeService([registration(1)]), self.store, account_id="acc")
|
||||||
|
records = self.store.records("acc")
|
||||||
|
write_records(self.dir / "o.csv", records, self.store.webhooks("acc"))
|
||||||
|
with (self.dir / "o.csv").open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
rows = list(csv.reader(handle, delimiter=";"))
|
||||||
|
self.assertEqual(rows[0][0], "#webhook")
|
||||||
|
self.assertEqual(rows[1][0], "registrationId")
|
||||||
|
self.assertEqual(rows[2][0], "id-1")
|
||||||
|
write_records(self.dir / "o.txt", records, [])
|
||||||
|
self.assertIn('"registrationId": "id-1"', (self.dir / "o.txt").read_text(encoding="utf-8"))
|
||||||
|
with self.assertRaises(ConfigurationError):
|
||||||
|
write_records(self.dir / "o.json", records, [])
|
||||||
|
|
||||||
|
def test_timestamped_path(self) -> None:
|
||||||
|
path = timestamped_path(Path("a/out.csv"), datetime(2026, 10, 23, 9, 5, 7))
|
||||||
|
self.assertEqual(path, Path("a/out-20261023-090507.csv"))
|
||||||
|
|
||||||
|
def test_polygon(self) -> None:
|
||||||
|
ring = parse_polygon("0,0;1,0;1,1;0,0")
|
||||||
|
self.assertEqual(ring[0], [0.0, 0.0])
|
||||||
|
with self.assertRaises(ConfigurationError):
|
||||||
|
parse_polygon("0,0;1,0;1,1")
|
||||||
|
|
||||||
|
def test_filter_validation(self) -> None:
|
||||||
|
good = load_point_alarm_filter(Path("nowcast-point-alarm-filter.example.json"))
|
||||||
|
self.assertIn("lightning", good)
|
||||||
|
bad = self.dir / "bad.json"
|
||||||
|
bad.write_text('{"thunderstorm": {"severityThreshold": "X"}}', encoding="utf-8")
|
||||||
|
with self.assertRaises(ConfigurationError):
|
||||||
|
load_point_alarm_filter(bad)
|
||||||
|
|
||||||
|
def test_read_id_list(self) -> None:
|
||||||
|
txt = self.dir / "ids.txt"
|
||||||
|
txt.write_text("# c\n\na\nb\na\n", encoding="utf-8")
|
||||||
|
self.assertEqual(read_id_list(txt, "registrationId"), ["a", "b"])
|
||||||
|
csv_file = self.dir / "ids.csv"
|
||||||
|
csv_file.write_text("x;recipientId\n1;r1\n2;r2\n", encoding="utf-8")
|
||||||
|
self.assertEqual(read_id_list(csv_file, "recipientId"), ["r1", "r2"])
|
||||||
|
with self.assertRaises(ConfigurationError):
|
||||||
|
read_id_list(csv_file, "registrationId")
|
||||||
|
|
||||||
|
def test_lookup_rows(self) -> None:
|
||||||
|
class Svc:
|
||||||
|
def get_by_id(self, value):
|
||||||
|
if value == "missing":
|
||||||
|
raise ApiError("nf", status_code=404, response_data={"m": 1})
|
||||||
|
if value == "boom":
|
||||||
|
raise ApiError("e", status_code=500, response_data="bad")
|
||||||
|
return ApiResponse(200, registration(1))
|
||||||
|
|
||||||
|
def get_by_recipient_id(self, value):
|
||||||
|
return ApiResponse(200, {"registrations": [registration(1), registration(2)]})
|
||||||
|
|
||||||
|
rows, _ = lookup_rows(Svc(), self.store, ["ok", "missing", "boom"], "id", None)
|
||||||
|
self.assertEqual([r["status"] for r in rows], ["SUCCESS", "NOT_FOUND", "FAILED"])
|
||||||
|
self.assertEqual(rows[2]["errorBody"], '"bad"')
|
||||||
|
rows, _ = lookup_rows(Svc(), self.store, ["r1"], "recipient", None)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(self.store.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
REG_ID = "f7587d9e-2481-4b4c-818d-c8d1946851b7"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegisterService:
|
||||||
|
def __init__(self, env_file: Path, results: list) -> None:
|
||||||
|
self.client = type("C", (), {"token_store": TokenStore(env_file)})()
|
||||||
|
self.results = list(results)
|
||||||
|
self.payloads: list[dict] = []
|
||||||
|
self.recipient_hits: list = []
|
||||||
|
|
||||||
|
def register(self, payload, key):
|
||||||
|
self.payloads.append(payload)
|
||||||
|
result = self.results.pop(0)
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_by_recipient_id(self, recipient_id):
|
||||||
|
return ApiResponse(200, {"registrations": self.recipient_hits})
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.dir = Path(self.tmp.name)
|
||||||
|
(self.dir / ".env").write_text("", encoding="utf-8")
|
||||||
|
self.filter = self.dir / "filter.json"
|
||||||
|
self.filter.write_text(json.dumps({"lightning": {"type": "PULSE_IN_CLOUD"}}), encoding="utf-8")
|
||||||
|
self.webhook = self.dir / "webhook.json"
|
||||||
|
self.webhook.write_text(json.dumps({
|
||||||
|
"url": "https://example.com/hook", "httpMethod": "POST", "contentType": "application/json",
|
||||||
|
"authentication": {"selected": "JWT_TOKEN", "options": {"JWT_TOKEN": {"jwtToken": "t"}}},
|
||||||
|
"deliveryPolicy": {"maxAttempts": 3},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
self.csv = self.dir / "r.csv"
|
||||||
|
|
||||||
|
def run_processor(self, rows: str | None, service) -> list[dict]:
|
||||||
|
if rows is not None:
|
||||||
|
self.csv.write_text("recipientId;lat;lng;radius\n" + rows, encoding="utf-8")
|
||||||
|
processor = PointRegistrationProcessor(
|
||||||
|
service, csv_path=self.csv, webhook_config_path=self.webhook, filter_config_path=self.filter
|
||||||
|
)
|
||||||
|
processor.run()
|
||||||
|
with self.csv.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
return list(csv.DictReader(handle, delimiter=";"))
|
||||||
|
|
||||||
|
def test_success_failure_and_skip(self) -> None:
|
||||||
|
service = FakeRegisterService(
|
||||||
|
self.dir / ".env",
|
||||||
|
[
|
||||||
|
ApiResponse(201, {"registrationId": REG_ID}),
|
||||||
|
ApiError("bad", status_code=400, response_data={"message": "duplicate"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
rows = self.run_processor("a;39.9;32.8;1000\nb;39.9;32.8;1000\nc;99;32.8;1000\n", service)
|
||||||
|
self.assertEqual([r["status"] for r in rows], ["SUCCESS", "FAILED", "FAILED"])
|
||||||
|
self.assertEqual(rows[0]["registrationId"], REG_ID)
|
||||||
|
self.assertIn("duplicate", rows[1]["errorBody"])
|
||||||
|
self.assertEqual(rows[2]["httpStatus"], "NOT_SENT")
|
||||||
|
self.assertEqual(service.payloads[0]["webhook"]["deliveryPolicy"], {"maxAttempts": 3})
|
||||||
|
self.assertNotIn("accountId", service.payloads[0]["webhook"])
|
||||||
|
self.assertEqual(service.payloads[0]["filter"]["lightning"]["type"], "PULSE_IN_CLOUD")
|
||||||
|
# Re-run: SUCCESS row is skipped, the failed one is retried.
|
||||||
|
service2 = FakeRegisterService(
|
||||||
|
self.dir / ".env", [ApiResponse(201, {"registrationId": REG_ID})] * 2
|
||||||
|
)
|
||||||
|
rows = self.run_processor(None, service2)
|
||||||
|
self.assertEqual(len(service2.payloads), 1)
|
||||||
|
self.assertEqual(rows[0]["registrationId"], REG_ID)
|
||||||
|
|
||||||
|
def test_recovery_requires_matching_point(self) -> None:
|
||||||
|
service = FakeRegisterService(self.dir / ".env", [ApiError("t", status_code=503)])
|
||||||
|
service.recipient_hits = [dict(registration(1, "a"), registrationId=REG_ID)]
|
||||||
|
rows = self.run_processor("a;39.9;32.8;1000\n", service)
|
||||||
|
self.assertEqual(rows[0]["status"], "SUCCESS")
|
||||||
|
self.assertEqual(rows[0]["registrationId"], REG_ID)
|
||||||
|
other = FakeRegisterService(self.dir / ".env", [ApiError("t", status_code=503)])
|
||||||
|
other.recipient_hits = [dict(registration(1, "a"), registrationId=REG_ID)]
|
||||||
|
rows = self.run_processor("a;40.0;32.8;1000\n", other)
|
||||||
|
self.assertEqual(rows[0]["status"], "FAILED")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateTest(unittest.TestCase):
|
||||||
|
def test_payload_skips_blank_cells(self) -> None:
|
||||||
|
payload = build_update_payload(
|
||||||
|
{"registrationId": REG_ID, "lat": "39.9", "lng": "32.8", "radius": "",
|
||||||
|
"severityThreshold": "high", "precipitationIntensities": "light|heavy"}
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["boundary"], {"point": {"lat": 39.9, "lng": 32.8}})
|
||||||
|
self.assertEqual(payload["filter"]["thunderstorm"], {"severityThreshold": "HIGH"})
|
||||||
|
self.assertEqual(payload["filter"]["precipitation"]["intensities"], ["LIGHT", "HEAVY"])
|
||||||
|
|
||||||
|
def test_payload_errors(self) -> None:
|
||||||
|
for row in (
|
||||||
|
{"registrationId": REG_ID},
|
||||||
|
{"registrationId": REG_ID, "lat": "39"},
|
||||||
|
{"registrationId": "x", "radius": "5"},
|
||||||
|
{"registrationId": REG_ID, "radius": "60000"},
|
||||||
|
{"registrationId": REG_ID, "lightningType": "BAD"},
|
||||||
|
):
|
||||||
|
with self.assertRaises(ConfigurationError, msg=row):
|
||||||
|
build_update_payload(row)
|
||||||
|
|
||||||
|
def test_dry_run_does_not_call_or_write(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "u.csv"
|
||||||
|
text = f"registrationId;radius\n{REG_ID};500\n"
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
class Boom:
|
||||||
|
def update(self, *_):
|
||||||
|
raise AssertionError("must not be called")
|
||||||
|
|
||||||
|
PointUpdateProcessor(Boom(), csv_path=path, dry_run=True).run()
|
||||||
|
self.assertEqual(path.read_text(encoding="utf-8"), text)
|
||||||
|
|
||||||
|
def test_update_records_result(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "u.csv"
|
||||||
|
path.write_text(f"registrationId;radius\n{REG_ID};500\n", encoding="utf-8")
|
||||||
|
|
||||||
|
class Svc:
|
||||||
|
def update(self, payload, key):
|
||||||
|
return ApiResponse(200, {})
|
||||||
|
|
||||||
|
PointUpdateProcessor(Svc(), csv_path=path).run()
|
||||||
|
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
row = next(csv.DictReader(handle, delimiter=";"))
|
||||||
|
self.assertEqual((row["status"], row["httpStatus"]), ("SUCCESS", "HTTP_200"))
|
||||||
|
|
||||||
|
|
||||||
|
class UnregisterTest(unittest.TestCase):
|
||||||
|
class Svc:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.deleted: list[str] = []
|
||||||
|
|
||||||
|
def get_by_id(self, value):
|
||||||
|
return ApiResponse(200, dict(registration(1), registrationId=value))
|
||||||
|
|
||||||
|
def unregister(self, value):
|
||||||
|
self.deleted.append(value)
|
||||||
|
return ApiResponse(204, None)
|
||||||
|
|
||||||
|
def make(self, svc, answers, env="test", backup=None):
|
||||||
|
queue = list(answers)
|
||||||
|
return PointUnregisterProcessor(
|
||||||
|
svc, ids=[REG_ID], environment=env, base_url="https://x",
|
||||||
|
backup_path=backup, input_fn=lambda _: queue.pop(0), out=io.StringIO(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wrong_confirmation_deletes_nothing(self) -> None:
|
||||||
|
svc = self.Svc()
|
||||||
|
self.assertEqual(self.make(svc, ["sil"]).run(), [])
|
||||||
|
self.assertEqual(svc.deleted, [])
|
||||||
|
|
||||||
|
def test_prod_needs_environment_name(self) -> None:
|
||||||
|
svc = self.Svc()
|
||||||
|
self.assertEqual(self.make(svc, ["test"], env="prod").run(), [])
|
||||||
|
self.assertEqual(svc.deleted, [])
|
||||||
|
self.assertEqual(len(self.make(svc, ["prod", "SİL"], env="prod").run()), 1)
|
||||||
|
|
||||||
|
def test_backup_written_before_delete_and_failure_blocks(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
svc = self.Svc()
|
||||||
|
backup = Path(tmp) / "b.csv"
|
||||||
|
results = self.make(svc, ["SİL"], backup=backup).run()
|
||||||
|
self.assertEqual(results[0]["status"], "SUCCESS")
|
||||||
|
self.assertIn(REG_ID, backup.read_text(encoding="utf-8-sig"))
|
||||||
|
svc2 = self.Svc()
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
self.make(svc2, ["SİL"], backup=Path(tmp) / "no" / "b.csv").run()
|
||||||
|
self.assertEqual(svc2.deleted, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user