feat: add nowcast point alarm unregister client with confirmation and backup

Shows environment, count and an irreversibility warning, requires typing the confirmation word (plus the environment name on prod), backs every registration up through get-by-id before deleting and aborts the whole run if the backup cannot be completed.
This commit is contained in:
2026-10-23 19:14:08 +03:00
parent 8587669ea5
commit 4d270aea05
4 changed files with 208 additions and 0 deletions
+2
View File
@@ -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"),
+86
View File
@@ -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())
+75
View File
@@ -943,3 +943,78 @@ class PointUpdateProcessor:
row["status"] = status row["status"] = status
row["httpStatus"] = http_status row["httpStatus"] = http_status
row["errorBody"] = error_body row["errorBody"] = error_body
CONFIRM_WORD = "SİL"
class PointUnregisterProcessor:
"""Delete registrations listed in a CSV/TXT after a typed confirmation and a backup."""
def __init__(
self,
service: PointAlarmService,
*,
ids: list[str],
environment: str,
base_url: str,
backup_path: Path | None,
input_fn: Callable[[str], str] = input,
out: TextIO | None = None,
) -> None:
self.service = service
self.ids = ids
self.environment = environment
self.base_url = base_url
self.backup_path = backup_path
self.input_fn = input_fn
self.out = out or sys.stdout
def confirm(self) -> bool:
say = lambda text: self.out.write(text + "\n")
say(f"Ortam: {self.environment or '-'} ({self.base_url})")
say(f"Silinecek kayıt sayısı: {len(self.ids)}")
say("Bu işlem geri alınamaz. Bir hesabın SON kaydı silinirse hesabın webhook tanımı da silinir.")
if self.environment == "prod":
if self.input_fn("PROD ortamında işlem yapıyorsunuz. Onaylamak için 'prod' yazın: ").strip() != "prod":
say("Ortam adı eşleşmedi; işlem iptal edildi.")
return False
if self.input_fn(f"Devam etmek için '{CONFIRM_WORD}' yazın: ").strip() != CONFIRM_WORD:
say("Onay verilmedi; işlem iptal edildi.")
return False
return True
def run(self) -> list[dict[str, Any]]:
if not self.confirm():
return []
backups: list[dict[str, Any]] = []
results: list[dict[str, Any]] = []
webhooks: list[dict[str, Any]] = []
if self.backup_path:
for registration_id in self.ids:
try:
data = self.service.get_by_id(registration_id).data
except ApiError as exc:
if exc.status_code == 404:
LOGGER.warning("%s bulunamadı; yedeklenemedi.", registration_id)
continue
# An unreadable record cannot be backed up, so nothing is deleted.
raise
if isinstance(data, dict):
backups.append(flatten_registration(data))
webhook = registration_webhook(data)
if webhook and webhook not in webhooks:
webhooks.append(webhook)
# A backup that cannot be written must stop the run before anything is deleted.
write_records(self.backup_path, backups, webhooks)
LOGGER.info("Yedek yazıldı: %s (%s kayıt)", self.backup_path, len(backups))
for registration_id in self.ids:
try:
response = self.service.unregister(registration_id)
except ApiError as exc:
results.append({"registrationId": registration_id, "status": "FAILED", "httpStatus": exc.status_code, "errorBody": error_body_text(exc)})
LOGGER.error("%s silinemedi: %s", registration_id, exc)
else:
results.append({"registrationId": registration_id, "status": "SUCCESS", "httpStatus": response.status_code, "errorBody": ""})
LOGGER.info("%s silindi.", registration_id)
return results
+45
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import csv import csv
import io
import json import json
import os import os
import tempfile import tempfile
@@ -15,6 +16,7 @@ from common import ApiError, ConfigurationError
from common.client import ApiResponse from common.client import ApiResponse
from point_alarm import ( from point_alarm import (
PointRegistrationProcessor, PointRegistrationProcessor,
PointUnregisterProcessor,
PointUpdateProcessor, PointUpdateProcessor,
build_update_payload, build_update_payload,
PointStore, PointStore,
@@ -294,5 +296,48 @@ class UpdateTest(unittest.TestCase):
self.assertEqual((row["status"], row["httpStatus"]), ("SUCCESS", "HTTP_200")) 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__": if __name__ == "__main__":
unittest.main() unittest.main()